Răsfoiți Sursa

Merge pull request #22184 from overleaf/ls-group-plan-upgrade-page

Group plan upgrade page

GitOrigin-RevId: 6c99173c013d84943276dbd43f468026c4d44558
Liangjun Song 1 an în urmă
părinte
comite
932f395932

+ 24 - 0
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,
+    })
+  }
 }
 
 /**

+ 34 - 1
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),
 }

+ 53 - 0
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,
   },
 }

+ 15 - 0
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/',

+ 15 - 0
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

+ 13 - 0
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": "",

+ 4 - 2
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) {
                 <h3 className="mb-0 fw-bold" data-testid="title">
                   {title}
                 </h3>
-                <div className="card-description-secondary">{content}</div>
+                {content && (
+                  <div className="card-description-secondary">{content}</div>
+                )}
               </div>
               <div className="text-center">
                 <Button variant="secondary" href="/user/subscription">

+ 67 - 0
services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-plan-details.tsx

@@ -0,0 +1,67 @@
+import getMeta from '@/utils/meta'
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Card, Row, Col } from 'react-bootstrap-5'
+import MaterialIcon from '@/shared/components/material-icon'
+import { formatCurrencyLocalized } from '@/shared/utils/currency'
+
+const LICENSE_ADD_ON = 'additional-license'
+
+function UpgradeSubscriptionPlanDetails() {
+  const { t } = useTranslation()
+  const preview = getMeta('ol-subscriptionChangePreview')
+
+  const licenseUnitPrice = useMemo(
+    () =>
+      preview.nextInvoice.addOns.filter(
+        addOn => addOn.code === LICENSE_ADD_ON
+      )[0].unitAmount,
+    [preview]
+  )
+
+  return (
+    <Card
+      className="group-subscription-upgrade-features card-description-secondary border-1"
+      border="light"
+    >
+      <Card.Body className="d-grid gap-3 p-3">
+        <b>{preview.nextInvoice.plan.name}</b>
+        <Row xs="auto" className="gx-2">
+          <Col>
+            <span className="per-user-price">
+              <b>
+                {formatCurrencyLocalized(
+                  licenseUnitPrice,
+                  preview.currency,
+                  getMeta('ol-i18n')?.currentLangCode ?? 'en',
+                  true
+                )}
+              </b>
+            </span>
+          </Col>
+          <Col className="d-flex flex-column justify-content-center">
+            <div className="per-user-price-text">{t('per_user')}</div>
+            <div className="per-user-price-text">{t('billed_yearly')}</div>
+          </Col>
+        </Row>
+        <div className="feature-list-item">
+          <b>{t('all_features_in_group_standard_plus')}</b>
+        </div>
+        <div className="ps-2 feature-list-item">
+          <MaterialIcon type="check" className="me-1" />
+          {t('unlimited_collaborators_per_project')}
+        </div>
+        <div className="ps-2 feature-list-item">
+          <MaterialIcon type="check" className="me-1" />
+          {t('sso')}
+        </div>
+        <div className="ps-2 feature-list-item">
+          <MaterialIcon type="check" className="me-1" />
+          {t('managed_user_accounts')}
+        </div>
+      </Card.Body>
+    </Card>
+  )
+}
+
+export default UpgradeSubscriptionPlanDetails

+ 88 - 0
services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-upgrade-summary.tsx

@@ -0,0 +1,88 @@
+import { 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 {
+  GroupPlanUpgrade,
+  SubscriptionChangePreview,
+} from '../../../../../../types/subscription/subscription-change-preview'
+import { MergeAndOverride } from '../../../../../../types/utils'
+import getMeta from '@/utils/meta'
+
+export type SubscriptionChange = MergeAndOverride<
+  SubscriptionChangePreview,
+  { change: GroupPlanUpgrade }
+>
+
+type UpgradeSummaryProps = {
+  subscriptionChange: SubscriptionChange
+}
+
+function UpgradeSummary({ subscriptionChange }: UpgradeSummaryProps) {
+  const { t } = useTranslation()
+  const totalLicenses = getMeta('ol-totalLicenses')
+
+  return (
+    <Card className="card-gray card-description-secondary">
+      <Card.Body className="d-grid gap-2 p-3">
+        <div>
+          <div className="fw-bold">{t('upgrade_summary')}</div>
+          {t('you_have_x_users_on_your_subscription', {
+            groupSize: totalLicenses,
+          })}
+        </div>
+        <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 {totalLicenses}{' '}
+                {t('users')}
+              </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')}</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_your_new_plan_based_on_the_remaining_months_of_your_current_subscription'
+          )}
+        </div>
+        <div>
+          {t('after_that_well_bill_you_x_annually_on_date_unless_you_cancel', {
+            subtotal: formatCurrencyLocalized(
+              subscriptionChange.nextInvoice.subtotal,
+              subscriptionChange.currency
+            ),
+            date: formatTime(subscriptionChange.nextInvoice.date, 'MMMM D'),
+          })}
+        </div>
+      </Card.Body>
+    </Card>
+  )
+}
+
+export default UpgradeSummary

+ 122 - 0
services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx

@@ -0,0 +1,122 @@
+import getMeta from '@/utils/meta'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { useTranslation, Trans } from 'react-i18next'
+import { Card, Row, Col } from 'react-bootstrap-5'
+import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
+import Button from '@/features/ui/components/bootstrap-5/button'
+import UpgradeSubscriptionPlanDetails from './upgrade-subscription-plan-details'
+import useAsync from '@/shared/hooks/use-async'
+import RequestStatus from '../request-status'
+import UpgradeSummary, {
+  SubscriptionChange,
+} from './upgrade-subscription-upgrade-summary'
+
+function UpgradeSubscription() {
+  const { t } = useTranslation()
+  const groupName = getMeta('ol-groupName')
+  const preview = getMeta('ol-subscriptionChangePreview') as SubscriptionChange
+  const { isError, runAsync, isSuccess, isLoading } = useAsync()
+  const onSubmit = () => {
+    runAsync(postJSON('/user/subscription/group/upgrade-subscription'))
+  }
+
+  if (isSuccess) {
+    return (
+      <RequestStatus
+        variant="primary"
+        icon="check_circle"
+        title={t('youve_upgraded_your_plan')}
+      />
+    )
+  }
+
+  if (isError) {
+    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" />]}
+          />
+        }
+      />
+    )
+  }
+
+  return (
+    <div className="container">
+      <Row>
+        <Col xl={{ span: 8, offset: 2 }}>
+          <div className="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 group-subscription-upgrade-card">
+            <Card.Body className="d-grid gap-2">
+              <b className="title">{t('upgrade_your_subscription')}</b>
+              <p>
+                <Trans
+                  i18nKey="group_plan_upgrade_description"
+                  values={{
+                    currentPlan: preview.change.prevPlan.name,
+                    nextPlan: preview.nextInvoice.plan.name,
+                  }}
+                  shouldUnescape
+                  tOptions={{ interpolation: { escapeValue: true } }}
+                  components={[
+                    // eslint-disable-next-line react/jsx-key
+                    <strong />,
+                    /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+                    <a href="/contact" />,
+                  ]}
+                />
+              </p>
+              <Row className="mb-2">
+                <Col md={{ span: 6 }} className="mb-2">
+                  <UpgradeSubscriptionPlanDetails />
+                </Col>
+                <Col md={{ span: 6 }}>
+                  <UpgradeSummary subscriptionChange={preview} />
+                </Col>
+              </Row>
+              <div className="d-flex align-items-center justify-content-end gap-2">
+                <a
+                  href="/user/subscription/group/add-users"
+                  className="me-auto"
+                >
+                  {t('add_more_users_to_my_plan')}
+                </a>
+                <Button
+                  href="/user/subscription"
+                  variant="secondary"
+                  disabled={isLoading}
+                >
+                  {t('cancel')}
+                </Button>
+                <Button
+                  variant="primary"
+                  onClick={onSubmit}
+                  isLoading={isLoading}
+                >
+                  {t('upgrade')}
+                </Button>
+              </div>
+            </Card.Body>
+          </Card>
+        </Col>
+      </Row>
+    </div>
+  )
+}
+
+export default UpgradeSubscription

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

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

+ 28 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss

@@ -382,3 +382,31 @@
     height: 400px;
   }
 }
+
+.group-subscription-upgrade-features {
+  .material-symbols {
+    @include body-sm;
+
+    vertical-align: top;
+  }
+
+  .per-user-price {
+    @include heading-lg;
+
+    color: var(--content-primary);
+  }
+
+  .per-user-price-text {
+    @include body-xs;
+  }
+
+  .feature-list-item {
+    @include body-sm;
+  }
+}
+
+.group-subscription-upgrade-card {
+  .title {
+    @include body-lg;
+  }
+}

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

@@ -87,6 +87,7 @@
   "add_more_managers": "Add more managers",
   "add_more_members": "Add more members",
   "add_more_users": "Add more users",
+  "add_more_users_to_my_plan": "Add more users to my plan",
   "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__",
@@ -128,6 +129,7 @@
   "alignment": "Alignment",
   "all": "All",
   "all_borders": "All borders",
+  "all_features_in_group_standard_plus": "All features in Group Standard, plus:",
   "all_our_group_plans_offer_educational_discount": "All of our <0>group plans</0> offer an <1>educational discount</1> for students and faculty",
   "all_premium_features": "All premium features",
   "all_premium_features_including": "All premium features, including:",
@@ -213,6 +215,7 @@
   "beta_program_opt_out_action": "Opt-Out of Beta Program",
   "better_bibliographies": "Better bibliographies",
   "bibliographies": "Bibliographies",
+  "billed_yearly": "billed yearly",
   "billing_period_sentence_case": "Billing period",
   "binary_history_error": "Preview not available for this file type",
   "blank_project": "Blank Project",
@@ -888,6 +891,7 @@
   "group_members_get_access_to_info": "These features are available only to group members (subscribers).",
   "group_plan_admins_can_easily_add_and_remove_users_from_a_group": "Group plan admins can easily add and remove users from a group. For site-wide plans, users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).",
   "group_plan_tooltip": "You are on the __plan__ plan as a member of a group subscription. Click to find out how to make the most of your Overleaf premium features.",
+  "group_plan_upgrade_description": "You’re on the <0>__currentPlan__</0> plan and you’re upgrading to the <0>__nextPlan__</0> plan. If you’re interested in a site-wide Overleaf Commons plan please <1>get in touch</1>.",
   "group_plan_with_name_tooltip": "You are on the __plan__ plan as a member of a group subscription, __groupName__. Click to find out how to make the most of your Overleaf premium features.",
   "group_plans": "Group Plans",
   "group_professional": "Group Professional",
@@ -2392,10 +2396,12 @@
   "upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
   "upgrade_my_plan": "Upgrade my plan",
   "upgrade_now": "Upgrade now",
+  "upgrade_summary": "Upgrade summary",
   "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.",
   "upgrade_to_get_feature": "Upgrade to get __feature__, plus:",
   "upgrade_to_track_changes": "Upgrade to track changes",
+  "upgrade_your_subscription": "Upgrade your subscription",
   "upload": "Upload",
   "upload_failed": "Upload failed",
   "upload_from_computer": "Upload from computer",
@@ -2427,6 +2433,7 @@
   "user_not_found": "User not found",
   "user_sessions": "User Sessions",
   "user_wants_you_to_see_project": "__username__ would like you to join __projectname__",
+  "users": "users",
   "using_latex": "Using LaTeX",
   "using_premium_features": "Using premium features",
   "using_the_overleaf_editor": "Using the __appName__ Editor",
@@ -2477,6 +2484,7 @@
   "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.",
+  "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "We’ll charge you now for your new plan 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",
@@ -2565,6 +2573,7 @@
   "you_have_been_invited_to_transfer_management_of_your_account_to": "You have been invited to transfer management of your account to __groupName__.",
   "you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard": "You have been removed from this project, and will no longer have access to it. You will be redirected to your project dashboard momentarily.",
   "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_plus_1": "You + 1",
   "you_plus_10": "You + 10",
@@ -2622,6 +2631,7 @@
   "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",
+  "youve_upgraded_your_plan": "You’ve upgraded your plan!",
   "zh-CN": "Chinese",
   "zip_contents_too_large": "Zip contents too large",
   "zoom_in": "Zoom in",

+ 11 - 0
services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs

@@ -77,6 +77,12 @@ describe('SubscriptionGroupController', function () {
       },
     }
 
+    this.RecurlyClient = {}
+
+    this.SubscriptionController = {}
+
+    this.SubscriptionModel = { Subscription: {} }
+
     this.Controller = await esmock.strict(modulePath, {
       '../../../../app/src/Features/Subscription/SubscriptionGroupHandler':
         this.SubscriptionGroupHandler,
@@ -94,6 +100,11 @@ describe('SubscriptionGroupController', function () {
         (this.ErrorController = {
           notFound: sinon.stub(),
         }),
+      '../../../../app/src/Features/Subscription/SubscriptionController':
+        this.SubscriptionController,
+      '../../../../app/src/Features/Subscription/RecurlyClient':
+        this.RecurlyClient,
+      '../../../../app/src/models/Subscription': this.SubscriptionModel,
     })
   })
 

+ 8 - 0
services/web/types/subscription/subscription-change-preview.ts

@@ -38,6 +38,7 @@ export type SubscriptionChangeDescription =
   | AddOnPurchase
   | AddOnUpdate
   | PremiumSubscription
+  | GroupPlanUpgrade
 
 export type AddOnPurchase = {
   type: 'add-on-purchase'
@@ -51,6 +52,13 @@ export type AddOnUpdate = {
   }
 }
 
+export type GroupPlanUpgrade = {
+  type: 'group-plan-upgrade'
+  prevPlan: {
+    name: string
+  }
+}
+
 type PremiumSubscription = {
   type: 'premium-subscription'
   plan: {