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

Merge pull request #21841 from overleaf/ii-flexible-group-licensing-add-seats

[web] Add seats to a group plan

GitOrigin-RevId: 53497d2cb7aa7d1e7dc8291e391b24f7a32eeece
ilkin-overleaf 1 год назад
Родитель
Сommit
ab4d8fe168
24 измененных файлов с 936 добавлено и 93 удалено
  1. 39 1
      services/web/app/src/Features/Subscription/RecurlyEntities.js
  2. 104 14
      services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
  3. 95 0
      services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
  4. 42 2
      services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
  5. 17 0
      services/web/app/views/subscriptions/add-seats.pug
  6. 0 13
      services/web/app/views/subscriptions/request-confirmation-react.pug
  7. 21 0
      services/web/frontend/extracted-translations.json
  8. 341 0
      services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx
  9. 121 0
      services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx
  10. 19 0
      services/web/frontend/js/features/group-management/components/add-seats/root.tsx
  11. 1 1
      services/web/frontend/js/features/group-management/components/group-members.tsx
  12. 21 9
      services/web/frontend/js/features/group-management/components/request-status.tsx
  13. 8 0
      services/web/frontend/js/pages/user/subscription/group-management/add-seats.tsx
  14. 0 8
      services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx
  15. 1 0
      services/web/frontend/js/utils/meta.ts
  16. 8 0
      services/web/frontend/stylesheets/bootstrap-5/components/card.scss
  17. 2 0
      services/web/frontend/stylesheets/bootstrap-5/components/list-group.scss
  18. 22 1
      services/web/locales/en.json
  19. 10 5
      services/web/test/frontend/features/group-management/components/request-status.spec.tsx
  20. 33 0
      services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js
  21. 9 17
      services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
  22. 10 0
      services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
  23. 1 18
      services/web/types/currency-code.ts
  24. 11 4
      services/web/types/subscription/subscription-change-preview.ts

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

@@ -151,6 +151,44 @@ class RecurlySubscription {
     })
   }
 
+  /**
+   * Update an add-on on this subscription
+   *
+   * @param {string} code
+   * @param {number} quantity
+   * @return {RecurlySubscriptionChangeRequest} - the change request to send to
+   * Recurly
+   *
+   * @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
+   */
+  getRequestForAddOnUpdate(code, quantity) {
+    if (!this.hasAddOn(code)) {
+      throw new AddOnNotPresentError(
+        'Subscription does not have add-on to update',
+        {
+          subscriptionId: this.id,
+          addOnCode: code,
+        }
+      )
+    }
+
+    const addOnUpdates = this.addOns.map(addOn => {
+      const update = addOn.toAddOnUpdate()
+
+      if (update.code === code) {
+        update.quantity = quantity
+      }
+
+      return update
+    })
+
+    return new RecurlySubscriptionChangeRequest({
+      subscription: this,
+      timeframe: 'now',
+      addOnUpdates,
+    })
+  }
+
   /**
    * Remove an add-on from this subscription
    *
@@ -162,7 +200,7 @@ class RecurlySubscription {
   getRequestForAddOnRemoval(code) {
     if (!this.hasAddOn(code)) {
       throw new AddOnNotPresentError(
-        'Subscripiton does not have add-on to remove',
+        'Subscription does not have add-on to remove',
         {
           subscriptionId: this.id,
           addOnCode: code,

+ 104 - 14
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -10,6 +10,8 @@ import { expressify } from '@overleaf/promise-utils'
 import Modules from '../../infrastructure/Modules.js'
 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 "../../../../types/subscription/dashboard/subscription"
@@ -113,7 +115,98 @@ async function _removeUserFromGroup(
   res.sendStatus(200)
 }
 
-async function requestConfirmation(req, res) {
+/**
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ * @returns {Promise<void>}
+ */
+async function addSeatsToGroupSubscription(req, res) {
+  try {
+    const { subscription, plan } =
+      await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
+        req
+      )
+    await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
+
+    res.render('subscriptions/add-seats', {
+      subscriptionId: subscription._id,
+      groupName: subscription.teamName,
+      totalLicenses: subscription.membersLimit,
+    })
+  } catch (error) {
+    logger.err(
+      { error },
+      'error while getting users group subscription details'
+    )
+    return res.redirect('/user/subscription')
+  }
+}
+
+/**
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ * @returns {Promise<void>}
+ */
+async function previewAddSeatsSubscriptionChange(req, res) {
+  try {
+    const preview =
+      await SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange(
+        req
+      )
+
+    res.json(preview)
+  } catch (error) {
+    logger.err(
+      { error },
+      'error trying to preview "add seats" subscription change'
+    )
+    return res.status(400).end()
+  }
+}
+
+/**
+ * @param {import("express").Request} req
+ * @param {import("express").Response} res
+ * @returns {Promise<void>}
+ */
+async function createAddSeatsSubscriptionChange(req, res) {
+  try {
+    const preview =
+      await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
+        req
+      )
+
+    res.json(preview)
+  } catch (error) {
+    logger.err(
+      { error },
+      'error trying to create "add seats" subscription change'
+    )
+    return res.status(400).end()
+  }
+}
+
+async function submitForm(req, res) {
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const userEmail = await UserGetter.promises.getUserEmail(userId)
+  const { adding } = req.body
+
+  req.body = {
+    email: userEmail,
+    subject: 'Self-Serve Group User Increase Request',
+    estimatedNumberOfUsers: adding,
+    message:
+      `This email has been generated on behalf of user with email **${userEmail}** ` +
+      'to request an increase in the total number of users ' +
+      `for their subscription.\n\n` +
+      `The requested number of users to add is: ${adding}`,
+    inbox: 'sales',
+  }
+
+  return SalesContactFormController.submitForm(req, res)
+}
+
+async function flexibleLicensingSplitTest(req, res, next) {
   const { variant } = await SplitTestHandler.promises.getAssignment(
     req,
     res,
@@ -124,22 +217,19 @@ async function requestConfirmation(req, res) {
     return ErrorController.notFound(req, res)
   }
 
-  try {
-    const userId = SessionManager.getLoggedInUserId(req.session)
-    const subscription =
-      await SubscriptionLocator.promises.getUsersSubscription(userId)
-
-    res.render('subscriptions/request-confirmation-react', {
-      groupName: subscription.teamName,
-    })
-  } catch (error) {
-    logger.err({ error }, 'error trying to request seats to subscription')
-    return res.render('/user/subscription')
-  }
+  next()
 }
 
 export default {
   removeUserFromGroup: expressify(removeUserFromGroup),
   removeSelfFromGroup: expressify(removeSelfFromGroup),
-  requestConfirmation: expressify(requestConfirmation),
+  addSeatsToGroupSubscription: expressify(addSeatsToGroupSubscription),
+  submitForm: expressify(submitForm),
+  flexibleLicensingSplitTest: expressify(flexibleLicensingSplitTest),
+  previewAddSeatsSubscriptionChange: expressify(
+    previewAddSeatsSubscriptionChange
+  ),
+  createAddSeatsSubscriptionChange: expressify(
+    createAddSeatsSubscriptionChange
+  ),
 }

+ 95 - 0
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js

@@ -1,7 +1,11 @@
 const { callbackify } = require('util')
 const SubscriptionUpdater = require('./SubscriptionUpdater')
 const SubscriptionLocator = require('./SubscriptionLocator')
+const SubscriptionController = require('./SubscriptionController')
 const { Subscription } = require('../../models/Subscription')
+const SessionManager = require('../Authentication/SessionManager')
+const RecurlyClient = require('./RecurlyClient')
+const PlansLocator = require('./PlansLocator')
 
 async function removeUserFromGroup(subscriptionId, userIdToRemove) {
   await SubscriptionUpdater.promises.removeUserFromGroup(
@@ -50,15 +54,106 @@ async function _replaceInArray(model, property, oldValue, newValue) {
   await model.updateMany(query, { $pull: setOldValue })
 }
 
+async function ensureFlexibleLicensingEnabled(plan) {
+  if (!plan?.canUseFlexibleLicensing) {
+    throw new Error('The group plan does not support flexible licencing')
+  }
+}
+
+async function getUsersGroupSubscriptionDetails(req) {
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const subscription =
+    await SubscriptionLocator.promises.getUsersSubscription(userId)
+
+  if (!subscription.groupPlan) {
+    throw new Error('User subscription is not a group plan')
+  }
+
+  const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
+
+  const recurlySubscription = await RecurlyClient.promises.getSubscription(
+    subscription.recurlySubscription_id
+  )
+
+  return {
+    subscription,
+    recurlySubscription,
+    plan,
+  }
+}
+
+async function _addSeatsSubscriptionChange(req) {
+  const adding = req.body.adding
+  const { recurlySubscription, plan } =
+    await getUsersGroupSubscriptionDetails(req)
+  await ensureFlexibleLicensingEnabled(plan)
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const currentAddonQuantity =
+    recurlySubscription.addOns.find(
+      addOn => addOn.code === plan.membersLimitAddOn
+    )?.quantity ?? 0
+  // Keeps only the new total quantity of addon
+  const nextAddonQuantity = currentAddonQuantity + adding
+  const changeRequest = recurlySubscription.getRequestForAddOnUpdate(
+    plan.membersLimitAddOn,
+    nextAddonQuantity
+  )
+
+  return {
+    changeRequest,
+    userId,
+    currentAddonQuantity,
+    recurlySubscription,
+    plan,
+  }
+}
+
+async function previewAddSeatsSubscriptionChange(req) {
+  const { changeRequest, userId, currentAddonQuantity, plan } =
+    await _addSeatsSubscriptionChange(req)
+  const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
+  const subscriptionChange =
+    await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
+  const subscriptionChangePreview =
+    await SubscriptionController.makeChangePreview(
+      {
+        type: 'add-on-update',
+        addOn: {
+          code: plan.membersLimitAddOn,
+          quantity: subscriptionChange.nextAddOns.find(
+            addon => addon.code === plan.membersLimitAddOn
+          ).quantity,
+          prevQuantity: currentAddonQuantity,
+        },
+      },
+      subscriptionChange,
+      paymentMethod
+    )
+
+  return subscriptionChangePreview
+}
+
+async function createAddSeatsSubscriptionChange(req) {
+  const { changeRequest } = await _addSeatsSubscriptionChange(req)
+  await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
+
+  return { adding: req.body.adding }
+}
+
 module.exports = {
   removeUserFromGroup: callbackify(removeUserFromGroup),
   replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
+  ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
   getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   promises: {
     removeUserFromGroup,
     replaceUserReferencesInGroups,
+    ensureFlexibleLicensingEnabled,
     getTotalConfirmedUsersInGroup,
     isUserPartOfGroup,
+    getUsersGroupSubscriptionDetails,
+    previewAddSeatsSubscriptionChange,
+    createAddSeatsSubscriptionChange,
   },
 }

+ 42 - 2
services/web/app/src/Features/Subscription/SubscriptionRouter.mjs

@@ -7,6 +7,7 @@ import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
 import Settings from '@overleaf/settings'
 import { Joi, validate } from '../../infrastructure/Validation.js'
+import SupportRouter from '../../../../modules/support/app/src/SupportRouter.mjs'
 
 const teamInviteRateLimiter = new RateLimiter('team-invite', {
   points: 10,
@@ -18,6 +19,14 @@ const subscriptionRateLimiter = new RateLimiter('subscription', {
   duration: 60,
 })
 
+const MAX_NUMBER_OF_USERS = 50
+
+const addSeatsValidateSchema = {
+  body: Joi.object({
+    adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
+  }),
+}
+
 export default {
   apply(webRouter, privateApiRouter, publicApiRouter) {
     if (!Settings.enableSubscriptions) {
@@ -62,10 +71,41 @@ export default {
     )
 
     webRouter.get(
-      '/user/subscription/group/request-confirmation',
+      '/user/subscription/group/add-users',
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionGroupController.flexibleLicensingSplitTest,
+      SubscriptionGroupController.addSeatsToGroupSubscription
+    )
+
+    webRouter.post(
+      '/user/subscription/group/add-users/preview',
+      AuthenticationController.requireLogin(),
+      validate(addSeatsValidateSchema),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionGroupController.previewAddSeatsSubscriptionChange
+    )
+
+    webRouter.post(
+      '/user/subscription/group/add-users/create',
       AuthenticationController.requireLogin(),
+      validate(addSeatsValidateSchema),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionGroupController.createAddSeatsSubscriptionChange
+    )
+
+    webRouter.post(
+      '/user/subscription/group/add-users/sales-contact-form',
+      validate({
+        body: Joi.object({
+          adding: Joi.number().integer().min(MAX_NUMBER_OF_USERS).required(),
+        }),
+      }),
+      RateLimiterMiddleware.rateLimit(
+        SupportRouter.rateLimiters.supportRequests
+      ),
       RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
-      SubscriptionGroupController.requestConfirmation
+      SubscriptionGroupController.submitForm
     )
 
     // Team invites

+ 17 - 0
services/web/app/views/subscriptions/add-seats.pug

@@ -0,0 +1,17 @@
+extends ../layout-marketing
+
+block vars
+	- bootstrap5PageStatus = 'enabled' // Enforce BS5 version
+
+block entrypointVar
+	- entrypoint = 'pages/user/subscription/group-management/add-seats'
+
+block append meta
+	meta(name="ol-subscriptionData" data-type="json" content=subscriptionData)
+	meta(name="ol-groupName", data-type="string", content=groupName)
+	meta(name="ol-subscriptionId", data-type="string", content=subscriptionId)
+	meta(name="ol-totalLicenses", data-type="number", content=totalLicenses)
+
+block content
+	main.content.content-alt#main-content
+		#add-seats-root

+ 0 - 13
services/web/app/views/subscriptions/request-confirmation-react.pug

@@ -1,13 +0,0 @@
-extends ../layout-marketing
-
-block vars
-	- bootstrap5PageStatus = 'enabled' // Enforce BS5 version
-
-block entrypointVar
-	- entrypoint = 'pages/user/subscription/group-management/request-confirmation'
-		
-block append meta
-	meta(name="ol-groupName", data-type="string", content=groupName)
-
-block content
-	main.content.content-alt#subscription-manage-group-root

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

@@ -82,6 +82,7 @@
   "add_role_and_department": "",
   "add_to_dictionary": "",
   "add_to_tag": "",
+  "add_users": "",
   "add_your_comment_here": "",
   "add_your_first_group_member_now": "",
   "added_by_on": "",
@@ -93,6 +94,7 @@
   "address_second_line_optional": "",
   "adjust_column_width": "",
   "advanced_reference_search_mode": "",
+  "after_that_well_bill_you_x_annually_on_date_unless_you_cancel": "",
   "aggregate_changed": "",
   "aggregate_to": "",
   "agree_with_the_terms": "",
@@ -291,6 +293,7 @@
   "copy_project": "",
   "copy_response": "",
   "copying": "",
+  "cost_summary": "",
   "country": "",
   "country_flag": "",
   "coupon_code": "",
@@ -456,6 +459,7 @@
   "enter_any_size_including_units_or_valid_latex_command": "",
   "enter_image_url": "",
   "enter_the_confirmation_code": "",
+  "enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "",
   "equation_preview": "",
   "error": "",
   "error_opening_document": "",
@@ -682,6 +686,7 @@
   "hotkey_undo": "",
   "hotkeys": "",
   "how_it_works": "",
+  "how_many_users_do_you_want_to_add": "",
   "how_to_create_tables": "",
   "how_to_insert_images": "",
   "how_we_use_your_data": "",
@@ -689,6 +694,8 @@
   "i_want_to_stay": "",
   "id": "",
   "if_you_need_to_customize_your_table_further_you_can": "",
+  "if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you": "",
+  "if_you_want_to_reduce_the_number_of_users_please_contact_support": "",
   "if_your_occupation_not_listed_type_full_name": "",
   "ignore_validation_errors": "",
   "ill_take_it": "",
@@ -760,6 +767,7 @@
   "ip_address": "",
   "is_email_affiliated": "",
   "issued_on": "",
+  "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
   "join_beta_program": "",
   "join_now": "",
   "join_overleaf_labs": "",
@@ -1281,6 +1289,7 @@
   "revoke_invite": "",
   "right": "",
   "role": "",
+  "sales_tax": "",
   "saml_auth_error": "",
   "saml_identity_exists_error": "",
   "saml_invalid_signature_error": "",
@@ -1324,6 +1333,7 @@
   "search_whole_word": "",
   "search_within_selection": "",
   "searched_path_for_lines_containing": "",
+  "seats": "",
   "security": "",
   "see_changes_in_your_documents_live": "",
   "select_a_column_or_a_merged_cell_to_align": "",
@@ -1361,6 +1371,7 @@
   "selection_deleted": "",
   "send_first_message": "",
   "send_message": "",
+  "send_request": "",
   "sending": "",
   "sent": "",
   "server_error": "",
@@ -1658,6 +1669,7 @@
   "tooltip_hide_pdf": "",
   "tooltip_show_filetree": "",
   "tooltip_show_pdf": "",
+  "total_due_today": "",
   "total_per_month": "",
   "total_per_year": "",
   "total_with_subtotal_and_tax": "",
@@ -1746,6 +1758,7 @@
   "upgrade": "",
   "upgrade_cc_btn": "",
   "upgrade_for_12x_more_compile_time": "",
+  "upgrade_my_plan": "",
   "upgrade_now": "",
   "upgrade_to_add_more_editors": "",
   "upgrade_to_add_more_editors_and_access_collaboration_features": "",
@@ -1777,6 +1790,9 @@
   "valid": "",
   "valid_sso_configuration": "",
   "validation_issue_entry_description": "",
+  "value_must_be_a_number": "",
+  "value_must_be_a_whole_number": "",
+  "value_must_be_at_least_x": "",
   "vat": "",
   "vat_number": "",
   "verify_email_address_before_enabling_managed_users": "",
@@ -1813,6 +1829,7 @@
   "we_got_your_request": "",
   "we_logged_you_in": "",
   "we_sent_new_code": "",
+  "we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "",
   "webinars": "",
   "website_status": "",
   "wed_love_you_to_stay": "",
@@ -1894,6 +1911,7 @@
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_compile_timed_out": "",
+  "your_current_plan_supports_up_to_x_users": "",
   "your_current_project_will_revert_to_the_version_from_time": "",
   "your_git_access_info": "",
   "your_git_access_info_bullet_1": "",
@@ -1922,10 +1940,13 @@
   "youre_about_to_disable_single_sign_on": "",
   "youre_about_to_enable_single_sign_on": "",
   "youre_about_to_enable_single_sign_on_sso_only": "",
+  "youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users": "",
   "youre_already_setup_for_sso": "",
   "youre_joining": "",
   "youre_on_free_trial_which_ends_on": "",
   "youre_signed_in_as_logout": "",
+  "youve_added_more_users": "",
+  "youve_added_x_more_users_to_your_subscription_invite_people": "",
   "youve_lost_edit_access": "",
   "youve_unlinked_all_users": "",
   "zoom_in": "",

+ 341 - 0
services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx

@@ -0,0 +1,341 @@
+import { useState, useEffect, useMemo, useRef } from 'react'
+import { debounce } from 'lodash'
+import { Trans, useTranslation } from 'react-i18next'
+import withErrorBoundary from '@/infrastructure/error-boundary'
+import useAbortController from '@/shared/hooks/use-abort-controller'
+import LoadingSpinner from '@/shared/components/loading-spinner'
+import Notification from '@/shared/components/notification'
+import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
+import {
+  Card,
+  Row,
+  Col,
+  FormGroup,
+  FormLabel,
+  FormControl,
+} from 'react-bootstrap-5'
+import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
+import Button from '@/features/ui/components/bootstrap-5/button'
+import CostSummary from '@/features/group-management/components/add-seats/cost-summary'
+import RequestStatus from '@/features/group-management/components/request-status'
+import useAsync from '@/shared/hooks/use-async'
+import getMeta from '@/utils/meta'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { debugConsole } from '@/utils/debugging'
+import * as yup from 'yup'
+import {
+  AddOnUpdate,
+  SubscriptionChangePreview,
+} from '../../../../../../types/subscription/subscription-change-preview'
+import { MergeAndOverride } from '../../../../../../types/utils'
+
+const MAX_NUMBER_OF_USERS = 50
+
+function AddSeats() {
+  const { t } = useTranslation()
+  const groupName = getMeta('ol-groupName')
+  const subscriptionId = getMeta('ol-subscriptionId')
+  const totalLicenses = Number(getMeta('ol-totalLicenses'))
+  const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
+  const [shouldContactSales, setShouldContactSales] = useState(false)
+  const controller = useAbortController()
+  const { signal: addSeatsSignal } = useAbortController()
+  const { signal: contactSalesSignal } = useAbortController()
+  const {
+    isLoading: isLoadingCostSummary,
+    runAsync: runAsyncCostSummary,
+    data: costSummaryData,
+    reset: resetCostSummaryData,
+  } = useAsync<
+    MergeAndOverride<SubscriptionChangePreview, { change: AddOnUpdate }>
+  >()
+  const {
+    isLoading: isAddingSeats,
+    isError: isErrorAddingSeats,
+    isSuccess: isSuccessAddingSeats,
+    runAsync: runAsyncAddSeats,
+    data: addedSeatsData,
+  } = useAsync<{ adding: number }>()
+  const {
+    isLoading: isSendingMailToSales,
+    isError: isErrorSendingMailToSales,
+    isSuccess: isSuccessSendingMailToSales,
+    runAsync: runAsyncSendMailToSales,
+  } = useAsync()
+
+  const addSeatsValidationSchema = useMemo(() => {
+    return yup
+      .number()
+      .typeError(t('value_must_be_a_number'))
+      .integer(t('value_must_be_a_whole_number'))
+      .min(1, t('value_must_be_at_least_x', { value: 1 }))
+      .required(t('this_field_is_required'))
+  }, [t])
+
+  const debouncedCostSummaryRequest = useMemo(
+    () =>
+      debounce((value: number, signal: AbortSignal) => {
+        const post = postJSON('/user/subscription/group/add-users/preview', {
+          signal,
+          body: { adding: value },
+        })
+        runAsyncCostSummary(post).catch(debugConsole.error)
+      }, 500),
+    [runAsyncCostSummary]
+  )
+
+  const validateSeats = async (value: string | undefined) => {
+    try {
+      await addSeatsValidationSchema.validate(value)
+      setAddSeatsInputError(undefined)
+
+      return true
+    } catch (error) {
+      if (error instanceof yup.ValidationError) {
+        setAddSeatsInputError(error.errors[0])
+      } else {
+        debugConsole.error(error)
+      }
+
+      return false
+    }
+  }
+
+  const handleSeatsChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const value = e.target.value === '' ? undefined : e.target.value
+    const isValidSeatsNumber = await validateSeats(value)
+    let shouldContactSales = false
+
+    if (isValidSeatsNumber) {
+      const seats = Number(value)
+
+      if (seats > MAX_NUMBER_OF_USERS) {
+        debouncedCostSummaryRequest.cancel()
+        shouldContactSales = true
+      } else {
+        debouncedCostSummaryRequest(seats, controller.signal)
+      }
+    } else {
+      debouncedCostSummaryRequest.cancel()
+    }
+
+    resetCostSummaryData()
+    setShouldContactSales(shouldContactSales)
+  }
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault()
+
+    const formData = new FormData(e.currentTarget)
+    const rawSeats =
+      formData.get('seats') === ''
+        ? undefined
+        : (formData.get('seats') as string)
+
+    if (!(await validateSeats(rawSeats))) {
+      return
+    }
+
+    if (shouldContactSales) {
+      const post = postJSON(
+        '/user/subscription/group/add-users/sales-contact-form',
+        {
+          signal: contactSalesSignal,
+          body: {
+            adding: rawSeats,
+          },
+        }
+      )
+      runAsyncSendMailToSales(post).catch(debugConsole.error)
+    } else {
+      const post = postJSON('/user/subscription/group/add-users/create', {
+        signal: addSeatsSignal,
+        body: { adding: Number(rawSeats) },
+      })
+      runAsyncAddSeats(post).catch(debugConsole.error)
+    }
+  }
+
+  useEffect(() => {
+    return () => {
+      debouncedCostSummaryRequest.cancel()
+    }
+  }, [debouncedCostSummaryRequest])
+
+  const formRef = useRef<HTMLFormElement>(null)
+
+  useEffect(() => {
+    const handleUnload = () => formRef.current?.reset()
+    window.addEventListener('beforeunload', handleUnload)
+
+    return () => window.removeEventListener('beforeunload', handleUnload)
+  }, [])
+
+  if (isErrorAddingSeats || isErrorSendingMailToSales) {
+    return (
+      <RequestStatus
+        variant="danger"
+        icon="error"
+        title={t('something_went_wrong')}
+        content={
+          <Trans
+            i18nKey="it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch"
+            // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+            components={[<a href="/contact" rel="noreferrer noopener" />]}
+          />
+        }
+      />
+    )
+  }
+
+  if (isSuccessAddingSeats) {
+    return (
+      <RequestStatus
+        variant="primary"
+        icon="check_circle"
+        title={t('youve_added_more_users')}
+        content={
+          <Trans
+            i18nKey="youve_added_x_more_users_to_your_subscription_invite_people"
+            components={[
+              // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+              <a
+                href={`/manage/groups/${subscriptionId}/members`}
+                rel="noreferrer noopener"
+              />,
+            ]}
+            values={{ users: addedSeatsData?.adding }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+          />
+        }
+      />
+    )
+  }
+
+  if (isSuccessSendingMailToSales) {
+    return (
+      <RequestStatus
+        icon="email"
+        title={t('we_got_your_request')}
+        content={t('our_team_will_get_back_to_you_shortly')}
+      />
+    )
+  }
+
+  return (
+    <div className="container">
+      <Row>
+        <Col xxl={5} xl={6} lg={7} md={9} className="mx-auto">
+          <div className="group-heading" data-testid="group-heading">
+            <IconButton
+              variant="ghost"
+              href="/user/subscription"
+              size="lg"
+              icon="arrow_back"
+              accessibilityLabel={t('back_to_subscription')}
+            />
+            <h2>{groupName || t('group_subscription')}</h2>
+          </div>
+          <Card className="card-description-secondary">
+            <Card.Body>
+              <form
+                noValidate
+                className="d-grid gap-4"
+                onSubmit={handleSubmit}
+                ref={formRef}
+              >
+                <div className="d-grid gap-1">
+                  <h4 className="fw-bold m-0 card-description-secondary">
+                    {t('add_more_users')}
+                  </h4>
+                  <div>
+                    {t('your_current_plan_supports_up_to_x_users', {
+                      users: totalLicenses,
+                    })}
+                  </div>
+                  <div>
+                    <Trans
+                      i18nKey="if_you_want_to_reduce_the_number_of_users_please_contact_support"
+                      // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                      components={[<a href="/contact" />]}
+                    />
+                  </div>
+                </div>
+                <div>
+                  <FormGroup controlId="number-of-users-input">
+                    <FormLabel>
+                      {t('how_many_users_do_you_want_to_add')}
+                    </FormLabel>
+                    <FormControl
+                      type="text"
+                      required
+                      className="w-25"
+                      name="seats"
+                      disabled={isLoadingCostSummary}
+                      onChange={handleSeatsChange}
+                      isInvalid={Boolean(addSeatsInputError)}
+                    />
+                    {Boolean(addSeatsInputError) && (
+                      <FormText type="error">{addSeatsInputError}</FormText>
+                    )}
+                  </FormGroup>
+                </div>
+                {isLoadingCostSummary ? (
+                  <LoadingSpinner className="ms-auto me-auto" />
+                ) : shouldContactSales ? (
+                  <div>
+                    <Notification
+                      content={
+                        <Trans
+                          i18nKey="if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you"
+                          // eslint-disable-next-line react/jsx-key
+                          components={[<b />]}
+                          values={{ count: 50 }}
+                          shouldUnescape
+                          tOptions={{ interpolation: { escapeValue: true } }}
+                        />
+                      }
+                      type="info"
+                    />
+                  </div>
+                ) : (
+                  <CostSummary
+                    subscriptionChange={costSummaryData}
+                    totalLicenses={totalLicenses}
+                  />
+                )}
+                <div className="d-flex align-items-center justify-content-end gap-2">
+                  <a
+                    href="user/subscription/group/upgrade-subscription"
+                    rel="noreferrer noopener"
+                    className="me-auto"
+                  >
+                    {t('upgrade_my_plan')}
+                  </a>
+                  <Button variant="secondary" href="/user/subscription">
+                    {t('cancel')}
+                  </Button>
+                  <Button
+                    variant="primary"
+                    type="submit"
+                    disabled={
+                      isAddingSeats ||
+                      isLoadingCostSummary ||
+                      isSendingMailToSales
+                    }
+                    isLoading={isAddingSeats || isSendingMailToSales}
+                  >
+                    {shouldContactSales ? t('send_request') : t('add_users')}
+                  </Button>
+                </div>
+              </form>
+            </Card.Body>
+          </Card>
+        </Col>
+      </Row>
+    </div>
+  )
+}
+
+export default withErrorBoundary(AddSeats)

+ 121 - 0
services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx

@@ -0,0 +1,121 @@
+import { Trans, useTranslation } from 'react-i18next'
+import { Card, ListGroup } from 'react-bootstrap-5'
+import { formatCurrencyLocalized } from '@/shared/utils/currency'
+import { formatTime } from '@/features/utils/format-date'
+import {
+  AddOnUpdate,
+  SubscriptionChangePreview,
+} from '../../../../../../types/subscription/subscription-change-preview'
+import { MergeAndOverride } from '../../../../../../types/utils'
+
+type CostSummaryProps = {
+  subscriptionChange: MergeAndOverride<
+    SubscriptionChangePreview,
+    { change: AddOnUpdate }
+  > | null
+  totalLicenses: number
+}
+
+function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
+  const { t } = useTranslation()
+
+  return (
+    <Card className="card-gray card-description-secondary">
+      <Card.Body className="d-grid gap-2 p-3">
+        <div>
+          <div className="fw-bold">{t('cost_summary')}</div>
+          {subscriptionChange ? (
+            <Trans
+              i18nKey="youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users"
+              components={[
+                <b />, // eslint-disable-line react/jsx-key
+                <b />, // eslint-disable-line react/jsx-key
+              ]}
+              values={{
+                adding:
+                  subscriptionChange.change.addOn.quantity -
+                  subscriptionChange.change.addOn.prevQuantity,
+                total:
+                  totalLicenses +
+                  subscriptionChange.change.addOn.quantity -
+                  subscriptionChange.change.addOn.prevQuantity,
+              }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+            />
+          ) : (
+            t(
+              'enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown'
+            )
+          )}
+        </div>
+        {subscriptionChange && (
+          <>
+            <div>
+              <ListGroup>
+                <ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
+                  <span className="me-auto">
+                    {subscriptionChange.nextInvoice.plan.name} x{' '}
+                    {subscriptionChange.change.addOn.quantity -
+                      subscriptionChange.change.addOn.prevQuantity}{' '}
+                    {t('seats')}
+                  </span>
+                  <span>
+                    {formatCurrencyLocalized(
+                      subscriptionChange.immediateCharge.subtotal,
+                      subscriptionChange.currency
+                    )}
+                  </span>
+                </ListGroup.Item>
+                <ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
+                  <span className="me-auto">
+                    {t('sales_tax')} &middot;{' '}
+                    {subscriptionChange.nextInvoice.tax.rate * 100}%
+                  </span>
+                  <span>
+                    {formatCurrencyLocalized(
+                      subscriptionChange.immediateCharge.tax,
+                      subscriptionChange.currency
+                    )}
+                  </span>
+                </ListGroup.Item>
+                <ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
+                  <strong className="me-auto">{t('total_due_today')}</strong>
+                  <strong>
+                    {formatCurrencyLocalized(
+                      subscriptionChange.immediateCharge.total,
+                      subscriptionChange.currency
+                    )}
+                  </strong>
+                </ListGroup.Item>
+              </ListGroup>
+              <hr className="m-0" />
+            </div>
+            <div>
+              {t(
+                'we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months'
+              )}
+            </div>
+            <div>
+              {t(
+                'after_that_well_bill_you_x_annually_on_date_unless_you_cancel',
+                {
+                  subtotal: formatCurrencyLocalized(
+                    subscriptionChange.nextInvoice.total,
+                    subscriptionChange.currency
+                  ),
+                  date: formatTime(
+                    subscriptionChange.nextInvoice.date,
+                    'MMMM D'
+                  ),
+                }
+              )}
+            </div>
+          </>
+        )}
+      </Card.Body>
+    </Card>
+  )
+}
+
+export default CostSummary

+ 19 - 0
services/web/frontend/js/features/group-management/components/add-seats/root.tsx

@@ -0,0 +1,19 @@
+import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
+import AddSeats from '@/features/group-management/components/add-seats/add-seats'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
+
+function Root() {
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
+
+  return (
+    <SplitTestProvider>
+      <AddSeats />
+    </SplitTestProvider>
+  )
+}
+
+export default Root

+ 1 - 1
services/web/frontend/js/features/group-management/components/group-members.tsx

@@ -66,7 +66,7 @@ export default function GroupMembers() {
             href="/user/subscription/group/add-users"
             rel="noreferrer noopener"
           >
-            {t('add_more_users')}
+            {t('add_more_users')}.
           </a>
         </small>
       )

+ 21 - 9
services/web/frontend/js/features/group-management/components/request-confirmation.tsx → services/web/frontend/js/features/group-management/components/request-status.tsx

@@ -4,15 +4,23 @@ import Button from '@/features/ui/components/bootstrap-5/button'
 import MaterialIcon from '@/shared/components/material-icon'
 import getMeta from '@/utils/meta'
 import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
+import classnames from 'classnames'
 
-function RequestConfirmation() {
+type RequestStatusProps = {
+  icon: string
+  title: string
+  content: React.ReactNode
+  variant?: 'primary' | 'danger'
+}
+
+function RequestStatus({ icon, title, content, variant }: RequestStatusProps) {
   const { t } = useTranslation()
   const groupName = getMeta('ol-groupName')
 
   return (
     <div className="container">
       <Row>
-        <Col xl={{ span: 4, offset: 4 }} md={{ span: 6, offset: 3 }}>
+        <Col xxl={5} xl={6} lg={7} md={9} className="mx-auto">
           <div className="group-heading" data-testid="group-heading">
             <IconButton
               variant="ghost"
@@ -25,14 +33,18 @@ function RequestConfirmation() {
           </div>
           <Card>
             <CardBody className="d-grid gap-3">
-              <div className="card-icon">
-                <MaterialIcon type="email" />
+              <div
+                className={classnames('card-icon', {
+                  [`text-${variant}`]: variant,
+                })}
+              >
+                <MaterialIcon type={icon} />
               </div>
               <div className="d-grid gap-2 text-center">
-                <h3 className="mb-0 fw-bold">{t('we_got_your_request')}</h3>
-                <div className="card-description-secondary">
-                  {t('our_team_will_get_back_to_you_shortly')}
-                </div>
+                <h3 className="mb-0 fw-bold" data-testid="title">
+                  {title}
+                </h3>
+                <div className="card-description-secondary">{content}</div>
               </div>
               <div className="text-center">
                 <Button variant="secondary" href="/user/subscription">
@@ -47,4 +59,4 @@ function RequestConfirmation() {
   )
 }
 
-export default RequestConfirmation
+export default RequestStatus

+ 8 - 0
services/web/frontend/js/pages/user/subscription/group-management/add-seats.tsx

@@ -0,0 +1,8 @@
+import '../base'
+import ReactDOM from 'react-dom'
+import Root from '@/features/group-management/components/add-seats/root'
+
+const element = document.getElementById('add-seats-root')
+if (element) {
+  ReactDOM.render(<Root />, element)
+}

+ 0 - 8
services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx

@@ -1,8 +0,0 @@
-import '../base'
-import ReactDOM from 'react-dom'
-import RequestConfirmation from '@/features/group-management/components/request-confirmation'
-
-const element = document.getElementById('subscription-manage-group-root')
-if (element) {
-  ReactDOM.render(<RequestConfirmation />, element)
-}

+ 1 - 0
services/web/frontend/js/utils/meta.ts

@@ -201,6 +201,7 @@ export interface Meta {
   'ol-tags': Tag[]
   'ol-teamInvites': TeamInvite[]
   'ol-thirdPartyIds': ThirdPartyIds
+  'ol-totalLicenses': number
   'ol-translationIoNotLoaded': string
   'ol-translationLoadErrorMessage': string
   'ol-translationMaintenance': string

+ 8 - 0
services/web/frontend/stylesheets/bootstrap-5/components/card.scss

@@ -196,6 +196,14 @@
   .material-symbols {
     font-size: 2rem;
   }
+
+  &.text-primary {
+    background-color: var(--bg-accent-03);
+  }
+
+  &.text-danger {
+    background-color: var(--bg-danger-03);
+  }
 }
 
 .card-description-secondary {

+ 2 - 0
services/web/frontend/stylesheets/bootstrap-5/components/list-group.scss

@@ -1,3 +1,5 @@
 .list-group-item {
+  display: flex;
+  align-items: center;
   min-height: 48px;
 }

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

@@ -86,7 +86,7 @@
   "add_more_editors": "Add more editors",
   "add_more_managers": "Add more managers",
   "add_more_members": "Add more members",
-  "add_more_users": "Add more users.",
+  "add_more_users": "Add more users",
   "add_new_email": "Add new email",
   "add_ons_are": "<strong>Add-ons:</strong> __addOnName__",
   "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
@@ -94,6 +94,7 @@
   "add_role_and_department": "Add role and department",
   "add_to_dictionary": "Add to Dictionary",
   "add_to_tag": "Add to tag",
+  "add_users": "Add users",
   "add_your_comment_here": "Add your comment here",
   "add_your_first_group_member_now": "Add your first group members now",
   "added": "added",
@@ -112,6 +113,7 @@
   "administration_and_security": "Administration and security",
   "advanced_reference_search": "Advanced <0>reference search</0>",
   "advanced_reference_search_mode": "Advanced reference search",
+  "after_that_well_bill_you_x_annually_on_date_unless_you_cancel": "After that, we’ll bill you __subtotal__ + applicable taxes annually on __date__, unless you cancel.",
   "aggregate_changed": "Changed",
   "aggregate_to": "to",
   "agree_with_the_terms": "I agree with the Overleaf terms",
@@ -393,6 +395,7 @@
   "copy_project": "Copy Project",
   "copy_response": "Copy response",
   "copying": "Copying",
+  "cost_summary": "Cost summary",
   "could_not_connect_to_collaboration_server": "Could not connect to collaboration server",
   "could_not_connect_to_websocket_server": "Could not connect to WebSocket server",
   "could_not_load_translations": "Could not load translations",
@@ -616,6 +619,7 @@
   "enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
   "enter_image_url": "Enter image URL",
   "enter_the_confirmation_code": "Enter the 6-digit confirmation code sent to __email__.",
+  "enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "Enter the number of users you’d like to add to see the cost breakdown.",
   "enter_your_email_address": "Enter your email address",
   "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password",
   "enter_your_new_password": "Enter your new password",
@@ -956,6 +960,7 @@
   "hotkeys": "Hotkeys",
   "how_it_works": "How it works",
   "how_many_users_do_you_need": "How many users do you need?",
+  "how_many_users_do_you_want_to_add": "How many users do you want to add?",
   "how_to_create_tables": "How to create tables",
   "how_to_insert_images": "How to insert images",
   "how_we_use_your_data": "How we use your data",
@@ -966,6 +971,8 @@
   "if_have_existing_can_link": "If you have an existing <b>__appName__</b> account on another email, you can link it to your <b>__institutionName__</b> account by clicking <b>__clickText__</b>.",
   "if_owner_can_link": "If you own the <b>__appName__</b> account with <b>__email__</b>, you will be allowed to link it to your <b>__institutionName__</b> institutional account.",
   "if_you_need_to_customize_your_table_further_you_can": "If you need to customize your table further, you can. Using LaTeX code, you can change anything from table styles and border styles to colors and column widths. <0>Read our guide</0> to using tables in LaTeX to help you get started.",
+  "if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you": "If you want more than __count__ users on your plan, we need to add them for you. Just click <0>Send request</0> below and we’ll be happy to help.",
+  "if_you_want_to_reduce_the_number_of_users_please_contact_support": "If you want to reduce the number of users on your plan, please <0>contact customer support</0>.",
   "if_your_occupation_not_listed_type_full_name": "If your __occupation__ isn’t listed, you can type the full name.",
   "ignore_and_continue_institution_linking": "You can also ignore this and <a href=\"__link__\">continue to __appName__ with your <b>__email__</b> account</a>.",
   "ignore_validation_errors": "Don’t check syntax",
@@ -1070,6 +1077,7 @@
   "is_not_used_on_any_other_website": "is not used on any other website",
   "issued_on": "Issued: __date__",
   "it": "Italian",
+  "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch</0> with our Support team for more help.",
   "ja": "Japanese",
   "january": "January",
   "join_beta_program": "Join beta program",
@@ -1808,6 +1816,7 @@
   "ro": "Romanian",
   "role": "Role",
   "ru": "Russian",
+  "sales_tax": "Sales tax",
   "saml": "SAML",
   "saml_auth_error": "Sorry, your identity provider responded with an error.  Please contact your administrator for more information.",
   "saml_authentication_required_error": "Other login methods have been disabled by your group administrator. Please use your group SSO login.",
@@ -1862,6 +1871,7 @@
   "search_whole_word": "Whole word",
   "search_within_selection": "Within selection",
   "searched_path_for_lines_containing": "Searched __path__ for lines containing \"__query__\"",
+  "seats": "Seats",
   "secondary_email_password_reset": "That email is registered as a secondary email. Please enter the primary email for your account.",
   "security": "Security",
   "see_changes_in_your_documents_live": "See changes in your documents, live",
@@ -1904,6 +1914,7 @@
   "send": "Send",
   "send_first_message": "Send your first message to your collaborators",
   "send_message": "Send message",
+  "send_request": "Send request",
   "send_test_email": "Send a test email",
   "sending": "Sending",
   "sent": "Sent",
@@ -2274,6 +2285,7 @@
   "tooltip_show_pdf": "Click to show the PDF",
   "top_pick": "Top pick",
   "total": "Total",
+  "total_due_today": "Total due today",
   "total_per_month": "Total per month",
   "total_per_year": "Total per year",
   "total_per_year_for_x_users": "total per year for __licenseSize__ users",
@@ -2378,6 +2390,7 @@
   "upgrade": "Upgrade",
   "upgrade_cc_btn": "Upgrade now, pay after 7 days",
   "upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
+  "upgrade_my_plan": "Upgrade my plan",
   "upgrade_now": "Upgrade now",
   "upgrade_to_add_more_editors": "Upgrade to add more editors to your project",
   "upgrade_to_add_more_editors_and_access_collaboration_features": "Upgrade to add more editors and access collaboration features like track changes and full project history.",
@@ -2420,6 +2433,9 @@
   "valid": "Valid",
   "valid_sso_configuration": "Valid SSO configuration",
   "validation_issue_entry_description": "A validation issue which prevented this project from compiling",
+  "value_must_be_a_number": "Value must be a number",
+  "value_must_be_a_whole_number": "Value must be a whole number",
+  "value_must_be_at_least_x": "Value must be at least __value__",
   "vat": "VAT",
   "vat_number": "VAT Number",
   "verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
@@ -2460,6 +2476,7 @@
   "we_logged_you_in": "We have logged you in.",
   "we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>We may also contact you</0> from time to time by email with a survey, or to see if you would like to participate in other user research initiatives",
   "we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.",
+  "we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "We’ll charge you now for the cost of your additional users based on the remaining months of your current subscription.",
   "webinars": "Webinars",
   "website_status": "Website status",
   "wed_love_you_to_stay": "We’d love you to stay",
@@ -2563,6 +2580,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_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.",
   "your_git_access_info_bullet_1": "You can have up to 10 tokens.",
@@ -2594,11 +2612,14 @@
   "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_sso_only": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct.",
+  "youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users": "You’re adding <0>__adding__</0> users to your plan giving you a total of <1>__total__</1> users.",
   "youre_already_setup_for_sso": "You’re already set up for SSO",
   "youre_joining": "You’re joining",
   "youre_on_free_trial_which_ends_on": "You’re on a free trial which ends on <0>__date__</0>.",
   "youre_signed_in_as_logout": "You’re signed in as <0>__email__</0>. <1>Log out.</1>",
   "youre_signed_up": "You’re signed up",
+  "youve_added_more_users": "You’ve added more users!",
+  "youve_added_x_more_users_to_your_subscription_invite_people": "You’ve added __users__ more users to your subscription. <0>Invite people</0>.",
   "youve_lost_edit_access": "You’ve lost edit access",
   "youve_unlinked_all_users": "You’ve unlinked all users",
   "zh-CN": "Chinese",

+ 10 - 5
services/web/test/frontend/features/group-management/components/request-confirmation.spec.tsx → services/web/test/frontend/features/group-management/components/request-status.spec.tsx

@@ -1,12 +1,14 @@
 import '../../../helpers/bootstrap-5'
-import RequestConfirmation from '@/features/group-management/components/request-confirmation'
+import RequestStatus from '@/features/group-management/components/request-status'
 
 describe('request confirmation page', function () {
   beforeEach(function () {
     cy.window().then(win => {
       win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
     })
-    cy.mount(<RequestConfirmation />)
+    cy.mount(
+      <RequestStatus icon="email" title="Test title" content="Test content" />
+    )
   })
 
   it('renders the back button', function () {
@@ -25,9 +27,12 @@ describe('request confirmation page', function () {
     })
   })
 
-  it('indicates the message was received', function () {
-    cy.findByRole('heading', { name: /we’ve got your request/i })
-    cy.findByText(/our team will get back to you shortly/i)
+  it('shows the title', function () {
+    cy.findByTestId('title').should('contain.text', 'Test title')
+  })
+
+  it('shows the content', function () {
+    cy.findByText('Test content')
   })
 
   it('renders the link to subscriptions', function () {

+ 33 - 0
services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js

@@ -192,6 +192,39 @@ describe('RecurlyEntities', function () {
         })
       })
 
+      describe('getRequestForAddOnUpdate()', function () {
+        it('returns a change request', function () {
+          const {
+            RecurlySubscriptionChangeRequest,
+            RecurlySubscriptionAddOnUpdate,
+          } = this.RecurlyEntities
+          const newQuantity = 2
+          const changeRequest = this.subscription.getRequestForAddOnUpdate(
+            'add-on-code',
+            newQuantity
+          )
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscription: this.subscription,
+              timeframe: 'now',
+              addOnUpdates: [
+                new RecurlySubscriptionAddOnUpdate({
+                  code: this.addOn.code,
+                  quantity: newQuantity,
+                  unitPrice: this.addOn.unitPrice,
+                }),
+              ],
+            })
+          )
+        })
+
+        it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function () {
+          expect(() =>
+            this.subscription.getRequestForAddOnUpdate('another-add-on', 2)
+          ).to.throw(Errors.AddOnNotPresentError)
+        })
+      })
+
       describe('getRequestForAddOnRemoval()', function () {
         it('returns a change request', function () {
           const changeRequest = this.subscription.getRequestForAddOnRemoval(

+ 9 - 17
services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs

@@ -1,6 +1,6 @@
 import esmock from 'esmock'
 import sinon from 'sinon'
-import { expect } from 'chai'
+
 const modulePath =
   '../../../../app/src/Features/Subscription/SubscriptionGroupController'
 
@@ -26,6 +26,7 @@ describe('SubscriptionGroupController', function () {
     this.subscription = {
       _id: this.subscriptionId,
       teamName: 'Cool group',
+      groupPlan: true,
     }
 
     this.SubscriptionGroupHandler = {
@@ -37,7 +38,6 @@ describe('SubscriptionGroupController', function () {
     this.SubscriptionLocator = {
       promises: {
         getSubscription: sinon.stub().resolves(this.subscription),
-        getUsersSubscription: sinon.stub().resolves(this.subscription),
       },
     }
 
@@ -71,6 +71,12 @@ describe('SubscriptionGroupController', function () {
       getAssignment: sinon.stub().yields(null, { variant: 'default' }),
     }
 
+    this.UserGetter = {
+      promises: {
+        getUserEmail: sinon.stub().resolves(this.user),
+      },
+    }
+
     this.Controller = await esmock.strict(modulePath, {
       '../../../../app/src/Features/Subscription/SubscriptionGroupHandler':
         this.SubscriptionGroupHandler,
@@ -83,6 +89,7 @@ describe('SubscriptionGroupController', function () {
       '../../../../app/src/infrastructure/Modules': this.Modules,
       '../../../../app/src/Features/SplitTests/SplitTestHandler':
         this.SplitTestHandler,
+      '../../../../app/src/Features/User/UserGetter': this.UserGetter,
       '../../../../app/src/Features/Errors/ErrorController':
         (this.ErrorController = {
           notFound: sinon.stub(),
@@ -269,19 +276,4 @@ describe('SubscriptionGroupController', function () {
       this.Controller.removeSelfFromGroup(this.req, res, done)
     })
   })
-
-  describe('add seats', function () {
-    it('render the request confirmation view', async function () {
-      this.SplitTestHandler.promises.getAssignment.resolves({
-        variant: 'enabled',
-      })
-      await this.Controller.requestConfirmation(this.req, {
-        render: (viewPath, viewParams) => {
-          expect(viewPath).to.equal('subscriptions/request-confirmation-react')
-          expect(viewParams.groupName).to.equal('Cool group')
-        },
-      })
-      expect(this.ErrorController.notFound).to.not.have.been.called
-    })
-  })
 })

+ 10 - 0
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -27,6 +27,10 @@ describe('SubscriptionGroupHandler', function () {
       },
     }
 
+    this.SubscriptionController = {
+      makeChangePreview: sinon.stub().resolves(),
+    }
+
     this.SubscriptionUpdater = {
       promises: {
         removeUserFromGroup: sinon.stub().resolves(),
@@ -40,13 +44,19 @@ describe('SubscriptionGroupHandler', function () {
       findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
     }
 
+    this.RecurlyClient = {
+      promises: {},
+    }
+
     this.Handler = SandboxedModule.require(modulePath, {
       requires: {
         './SubscriptionUpdater': this.SubscriptionUpdater,
         './SubscriptionLocator': this.SubscriptionLocator,
+        './SubscriptionController': this.SubscriptionController,
         '../../models/Subscription': {
           Subscription: this.Subscription,
         },
+        './RecurlyClient': this.RecurlyClient,
       },
     })
   })

+ 1 - 18
services/web/types/currency-code.ts

@@ -1,18 +1 @@
-export type CurrencyCode =
-  | 'AUD'
-  | 'BRL'
-  | 'CAD'
-  | 'CHF'
-  | 'CLP'
-  | 'COP'
-  | 'DKK'
-  | 'EUR'
-  | 'GBP'
-  | 'INR'
-  | 'MXN'
-  | 'NOK'
-  | 'NZD'
-  | 'PEN'
-  | 'SEK'
-  | 'SGD'
-  | 'USD'
+export type { CurrencyCode } from './subscription/currency'

+ 11 - 4
services/web/types/subscription/subscription-change-preview.ts

@@ -34,13 +34,20 @@ type AddOn = {
   amount: number
 }
 
-export type SubscriptionChangeDescription = AddOnPurchase | PremiumSubscription
+export type SubscriptionChangeDescription =
+  | AddOnPurchase
+  | AddOnUpdate
+  | PremiumSubscription
 
 export type AddOnPurchase = {
   type: 'add-on-purchase'
-  addOn: {
-    code: string
-    name: string
+  addOn: Pick<AddOn, 'code' | 'name'>
+}
+
+export type AddOnUpdate = {
+  type: 'add-on-update'
+  addOn: Pick<AddOn, 'code' | 'quantity'> & {
+    prevQuantity: AddOn['quantity']
   }
 }