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

Merge pull request #23117 from overleaf/ii-flexible-group-licensing-subscription-page

[web] Subscription page for flexible licensing

GitOrigin-RevId: 8f2fab1fc01e27063d716a86add66b1b9a72cbe6
ilkin-overleaf 1 год назад
Родитель
Сommit
4a927e45a6
36 измененных файлов с 1060 добавлено и 67 удалено
  1. 1 0
      services/web/app/src/Features/Subscription/RecurlyEntities.js
  2. 46 0
      services/web/app/src/Features/Subscription/SubscriptionController.js
  3. 3 0
      services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
  4. 12 1
      services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
  5. 77 9
      services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
  6. 2 0
      services/web/app/views/subscriptions/dashboard-react.pug
  7. 18 0
      services/web/frontend/extracted-translations.json
  8. 1 6
      services/web/frontend/js/features/group-management/components/add-seats/root.tsx
  9. 14 0
      services/web/frontend/js/features/group-management/components/upgrade-subscription/root.tsx
  10. 94 6
      services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx
  11. 31 4
      services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
  12. 7 1
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
  13. 19 9
      services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx
  14. 1 1
      services/web/frontend/js/features/subscription/components/dashboard/row-link.tsx
  15. 297 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
  16. 144 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx
  17. 8 4
      services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx
  18. 4 3
      services/web/frontend/js/features/subscription/components/dashboard/states/canceled.tsx
  19. 7 5
      services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx
  20. 8 2
      services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx
  21. 1 0
      services/web/frontend/js/features/ui/components/ol/ol-tag.tsx
  22. 2 2
      services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx
  23. 8 1
      services/web/frontend/js/shared/components/tag.tsx
  24. 1 0
      services/web/frontend/js/shared/svgs/sparkle.svg
  25. 1 0
      services/web/frontend/js/utils/meta.ts
  26. 60 5
      services/web/frontend/stylesheets/app/subscription.less
  27. 5 0
      services/web/frontend/stylesheets/bootstrap-5/components/badge.scss
  28. 48 1
      services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss
  29. 1 0
      services/web/frontend/stylesheets/components/badge.less
  30. 5 0
      services/web/frontend/stylesheets/components/group-members.less
  31. 18 0
      services/web/locales/en.json
  32. 33 0
      services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
  33. 19 0
      services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
  34. 59 6
      services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
  35. 4 1
      services/web/types/subscription/dashboard/subscription.ts
  36. 1 0
      services/web/types/subscription/plan.ts

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

@@ -425,6 +425,7 @@ function isStandaloneAiAddOnPlanCode(planCode) {
 module.exports = {
   AI_ADD_ON_CODE,
   MEMBERS_LIMIT_ADD_ON_CODE,
+  STANDALONE_AI_ADD_ON_CODES,
   RecurlySubscription,
   RecurlySubscriptionAddOn,
   RecurlySubscriptionChange,

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

@@ -61,6 +61,13 @@ async function userSubscriptionPage(req, res) {
     'bootstrap-5-subscription'
   )
 
+  const { variant: flexibleLicensingVariant } =
+    await SplitTestHandler.promises.getAssignment(
+      req,
+      res,
+      'flexible-group-licensing'
+    )
+
   const results =
     await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
       user,
@@ -124,6 +131,42 @@ async function userSubscriptionPage(req, res) {
     )
   }
 
+  let groupSettingsAdvertisedFor
+  try {
+    const managedGroups = await async.filter(
+      managedGroupSubscriptions || [],
+      async subscription => {
+        const managedUsersResults = await Modules.promises.hooks.fire(
+          'hasManagedUsersFeatureOnNonProfessionalPlan',
+          subscription
+        )
+        const groupSSOResults = await Modules.promises.hooks.fire(
+          'hasGroupSSOFeatureOnNonProfessionalPlan',
+          subscription
+        )
+        const isGroupAdmin =
+          (subscription.admin_id._id || subscription.admin_id).toString() ===
+          user._id.toString()
+        const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
+        return (
+          (managedUsersResults?.[0] === true ||
+            groupSSOResults?.[0] === true) &&
+          isGroupAdmin &&
+          flexibleLicensingVariant === 'enabled' &&
+          plan?.canUseFlexibleLicensing
+        )
+      }
+    )
+    groupSettingsAdvertisedFor = managedGroups.map(subscription =>
+      subscription._id.toString()
+    )
+  } catch (error) {
+    logger.error(
+      { err: error },
+      'Failed to list groups with group settings enabled for advertising'
+    )
+  }
+
   const data = {
     title: 'your_subscription',
     plans: plansData?.plans,
@@ -138,7 +181,10 @@ async function userSubscriptionPage(req, res) {
     managedInstitutions,
     managedPublishers,
     currentInstitutionsWithLicence,
+    canUseFlexibleLicensing:
+      personalSubscription?.plan?.canUseFlexibleLicensing,
     groupPlans: groupPlansDataForDash,
+    groupSettingsAdvertisedFor,
     groupSettingsEnabledFor,
     isManagedAccount: !!req.managedBy,
     userRestrictions: Array.from(req.userRestrictions || []),

+ 3 - 0
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -133,6 +133,9 @@ async function addSeatsToGroupSubscription(req, res) {
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
     // Check if the user has missing billing details
     await RecurlyClient.promises.getPaymentMethod(userId)
+    await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
+      subscription
+    )
 
     res.render('subscriptions/add-seats', {
       subscriptionId: subscription._id,

+ 12 - 1
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js

@@ -62,6 +62,12 @@ async function ensureFlexibleLicensingEnabled(plan) {
   }
 }
 
+async function ensureSubscriptionIsActive(subscription) {
+  if (subscription?.recurlyStatus?.state !== 'active') {
+    throw new Error('The subscription is not active')
+  }
+}
+
 async function getUsersGroupSubscriptionDetails(userId) {
   const subscription =
     await SubscriptionLocator.promises.getUsersSubscription(userId)
@@ -89,9 +95,10 @@ async function getUsersGroupSubscriptionDetails(userId) {
 }
 
 async function _addSeatsSubscriptionChange(userId, adding) {
-  const { recurlySubscription, plan } =
+  const { subscription, recurlySubscription, plan } =
     await getUsersGroupSubscriptionDetails(userId)
   await ensureFlexibleLicensingEnabled(plan)
+  await ensureSubscriptionIsActive(subscription)
   const currentAddonQuantity =
     recurlySubscription.addOns.find(
       addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE
@@ -193,6 +200,8 @@ async function _getGroupPlanUpgradeChangeRequest(ownerId) {
   const olSubscription =
     await SubscriptionLocator.promises.getUsersSubscription(ownerId)
 
+  await ensureSubscriptionIsActive(olSubscription)
+
   const newPlanCode = await _getUpgradeTargetPlanCodeMaybeThrow(olSubscription)
   const recurlySubscription = await RecurlyClient.promises.getSubscription(
     olSubscription.recurlySubscription_id
@@ -234,6 +243,7 @@ module.exports = {
   removeUserFromGroup: callbackify(removeUserFromGroup),
   replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
   ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
+  ensureSubscriptionIsActive: callbackify(ensureSubscriptionIsActive),
   getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
@@ -242,6 +252,7 @@ module.exports = {
     removeUserFromGroup,
     replaceUserReferencesInGroups,
     ensureFlexibleLicensingEnabled,
+    ensureSubscriptionIsActive,
     getTotalConfirmedUsersInGroup,
     isUserPartOfGroup,
     getUsersGroupSubscriptionDetails,

+ 77 - 9
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

@@ -2,7 +2,10 @@
 const Settings = require('@overleaf/settings')
 const RecurlyWrapper = require('./RecurlyWrapper')
 const PlansLocator = require('./PlansLocator')
-const { isStandaloneAiAddOnPlanCode } = require('./RecurlyEntities')
+const {
+  isStandaloneAiAddOnPlanCode,
+  MEMBERS_LIMIT_ADD_ON_CODE,
+} = require('./RecurlyEntities')
 const SubscriptionFormatters = require('./SubscriptionFormatters')
 const SubscriptionLocator = require('./SubscriptionLocator')
 const SubscriptionUpdater = require('./SubscriptionUpdater')
@@ -240,6 +243,51 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
     delete personalSubscription.recurly
   }
 
+  function getPlanOnlyDisplayPrice(
+    totalPlanPriceInCents,
+    taxRate,
+    addOns = []
+  ) {
+    // The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model
+    const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce(
+      (prev, curr) => {
+        return curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE
+          ? curr.quantity * curr.unit_amount_in_cents + prev
+          : prev
+      },
+      0
+    )
+    const allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice =
+      allAddOnsPriceInCentsExceptAdditionalLicensePrice +
+      allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate
+
+    return SubscriptionFormatters.formatPriceLocalized(
+      totalPlanPriceInCents -
+        allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice,
+      recurlySubscription.currency,
+      locale
+    )
+  }
+
+  function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) {
+    return addOns.reduce((prev, curr) => {
+      if (curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE) {
+        const priceInCents = curr.quantity * curr.unit_amount_in_cents
+        const totalPriceInCents = priceInCents + priceInCents * taxRate
+
+        if (totalPriceInCents > 0) {
+          prev[curr.add_on_code] = SubscriptionFormatters.formatPriceLocalized(
+            totalPriceInCents,
+            recurlySubscription.currency,
+            locale
+          )
+        }
+      }
+
+      return prev
+    }, {})
+  }
+
   if (personalSubscription && recurlySubscription) {
     const tax = recurlySubscription.tax_in_cents || 0
     // Some plans allow adding more seats than the base plan provides.
@@ -248,6 +296,9 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
     let addOnPrice = 0
     let additionalLicenses = 0
     const addOns = recurlySubscription.subscription_add_ons || []
+    const taxRate = recurlySubscription.tax_rate
+      ? parseFloat(recurlySubscription.tax_rate._)
+      : 0
     addOns.forEach(addOn => {
       addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
       if (addOn.add_on_code === plan.membersLimitAddOn) {
@@ -257,9 +308,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
     const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
     personalSubscription.recurly = {
       tax,
-      taxRate: recurlySubscription.tax_rate
-        ? parseFloat(recurlySubscription.tax_rate._)
-        : 0,
+      taxRate,
       billingDetailsLink: buildHostedLink('billing-details'),
       accountManagementLink: buildHostedLink('account-management'),
       additionalLicenses,
@@ -311,12 +360,14 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
       const pendingSubscriptionTax =
         personalSubscription.recurly.taxRate *
         recurlySubscription.pending_subscription.unit_amount_in_cents
+      const totalPriceInCents =
+        recurlySubscription.pending_subscription.unit_amount_in_cents +
+        pendingAddOnPrice +
+        pendingAddOnTax +
+        pendingSubscriptionTax
       personalSubscription.recurly.displayPrice =
         SubscriptionFormatters.formatPriceLocalized(
-          recurlySubscription.pending_subscription.unit_amount_in_cents +
-            pendingAddOnPrice +
-            pendingAddOnTax +
-            pendingSubscriptionTax,
+          totalPriceInCents,
           recurlySubscription.currency,
           locale
         )
@@ -326,6 +377,17 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
           recurlySubscription.currency,
           locale
         )
+      personalSubscription.recurly.planOnlyDisplayPrice =
+        getPlanOnlyDisplayPrice(
+          totalPriceInCents,
+          taxRate,
+          recurlySubscription.pending_subscription.subscription_add_ons
+        )
+      personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
+        getAddOnDisplayPricesWithoutAdditionalLicense(
+          taxRate,
+          recurlySubscription.pending_subscription.subscription_add_ons
+        )
       const pendingTotalLicenses =
         (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
       personalSubscription.recurly.pendingAdditionalLicenses =
@@ -333,12 +395,18 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
       personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
       personalSubscription.pendingPlan = pendingPlan
     } else {
+      const totalPriceInCents =
+        recurlySubscription.unit_amount_in_cents + addOnPrice + tax
       personalSubscription.recurly.displayPrice =
         SubscriptionFormatters.formatPriceLocalized(
-          recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
+          totalPriceInCents,
           recurlySubscription.currency,
           locale
         )
+      personalSubscription.recurly.planOnlyDisplayPrice =
+        getPlanOnlyDisplayPrice(totalPriceInCents, taxRate, addOns)
+      personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
+        getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns)
     }
   }
 

+ 2 - 0
services/web/app/views/subscriptions/dashboard-react.pug

@@ -22,6 +22,8 @@ block append meta
 	meta(name="ol-hasSubscription" data-type="boolean" content=hasSubscription)
 	meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage)
 	meta(name="ol-plans", data-type="json" content=plans)
+	meta(name="ol-groupSettingsAdvertisedFor", data-type="json" content=groupSettingsAdvertisedFor)
+	meta(name="ol-canUseFlexibleLicensing", data-type="boolean", content=canUseFlexibleLicensing)
 	meta(name="ol-groupSettingsEnabledFor", data-type="json" content=groupSettingsEnabledFor)
 	meta(name="ol-user" data-type="json" content=user)
 	if (personalSubscription && personalSubscription.recurly)

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

@@ -79,6 +79,7 @@
   "add_more_users_to_my_plan": "",
   "add_new_email": "",
   "add_on": "",
+  "add_ons": "",
   "add_ons_are": "",
   "add_or_remove_project_from_tag": "",
   "add_people": "",
@@ -153,6 +154,7 @@
   "autocompile_disabled_reason": "",
   "autocomplete": "",
   "autocomplete_references": "",
+  "available_with_group_professional": "",
   "back": "",
   "back_to_configuration": "",
   "back_to_editor": "",
@@ -163,7 +165,10 @@
   "beta_program_already_participating": "",
   "beta_program_benefits": "",
   "beta_program_not_participating": "",
+  "billed_annually_at": "",
+  "billed_monthly_at": "",
   "billed_yearly": "",
+  "billing": "",
   "binary_history_error": "",
   "blank_project": "",
   "blocked_filename": "",
@@ -582,6 +587,7 @@
   "get_in_touch": "",
   "get_more_compile_time": "",
   "get_most_subscription_by_checking_features": "",
+  "get_most_subscription_discover_premium_features": "",
   "get_symbol_palette": "",
   "get_track_changes": "",
   "git": "",
@@ -638,9 +644,13 @@
   "group_invite_has_been_sent_to_email": "",
   "group_libraries": "",
   "group_managed_by_group_administrator": "",
+  "group_management": "",
+  "group_managers": "",
+  "group_members": "",
   "group_plan_tooltip": "",
   "group_plan_upgrade_description": "",
   "group_plan_with_name_tooltip": "",
+  "group_settings": "",
   "group_sso_configuration_idp_metadata": "",
   "group_sso_configure_service_provider_in_idp": "",
   "group_sso_documentation_links": "",
@@ -1290,6 +1300,7 @@
   "removing": "",
   "rename": "",
   "rename_project": "",
+  "renews_on": "",
   "reopen": "",
   "reopen_comment_error_message": "",
   "reopen_comment_error_title": "",
@@ -1564,6 +1575,8 @@
   "suggested_fix_for_error_in_path": "",
   "suggestion_applied": "",
   "support_for_your_browser_is_ending_soon": "",
+  "supports_up_to_x_users": "",
+  "supports_up_to_x_users_incl_y_additional_licenses": "",
   "sure_you_want_to_cancel_plan_change": "",
   "sure_you_want_to_change_plan": "",
   "sure_you_want_to_delete": "",
@@ -1820,6 +1833,7 @@
   "upgrade_for_12x_more_compile_time": "",
   "upgrade_my_plan": "",
   "upgrade_now": "",
+  "upgrade_plan": "",
   "upgrade_summary": "",
   "upgrade_to_add_more_editors_and_access_collaboration_features": "",
   "upgrade_to_get_feature": "",
@@ -1832,6 +1846,7 @@
   "url_to_fetch_the_file_from": "",
   "us_gov_banner_government_purchasing": "",
   "us_gov_banner_small_business_reseller": "",
+  "usage_metrics": "",
   "use_a_different_password": "",
   "use_saml_metadata_to_configure_sso_with_idp": "",
   "use_your_own_machine": "",
@@ -1861,6 +1876,7 @@
   "verify_email_address_before_enabling_managed_users": "",
   "view": "",
   "view_all": "",
+  "view_billing_details": "",
   "view_code": "",
   "view_configuration": "",
   "view_group_members": "",
@@ -1934,6 +1950,7 @@
   "x_price_for_first_month": "",
   "x_price_for_first_year": "",
   "x_price_for_y_months": "",
+  "x_price_per_month": "",
   "x_price_per_user": "",
   "x_price_per_year": "",
   "year": "",
@@ -1960,6 +1977,7 @@
   "you_can_still_use_your_premium_features": "",
   "you_cant_add_or_change_password_due_to_sso": "",
   "you_cant_join_this_group_subscription": "",
+  "you_dont_have_any_add_ons_on_your_account": "",
   "you_dont_have_any_repositories": "",
   "you_have_0_free_suggestions_left": "",
   "you_have_1_free_suggestion_left": "",

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

@@ -1,6 +1,5 @@
 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()
@@ -9,11 +8,7 @@ function Root() {
     return null
   }
 
-  return (
-    <SplitTestProvider>
-      <AddSeats />
-    </SplitTestProvider>
-  )
+  return <AddSeats />
 }
 
 export default Root

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

@@ -0,0 +1,14 @@
+import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
+import UpgradeSubscription from '@/features/group-management/components/upgrade-subscription/upgrade-subscription'
+
+function Root() {
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
+
+  return <UpgradeSubscription />
+}
+
+export default Root

+ 94 - 6
services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx

@@ -1,17 +1,49 @@
 import { RowLink } from '@/features/subscription/components/dashboard/row-link'
 import { useTranslation } from 'react-i18next'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+import { useLocation } from '@/shared/hooks/use-location'
+import MaterialIcon from '@/shared/components/material-icon'
+import OLTag from '@/features/ui/components/ol/ol-tag'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
 
-export default function GroupSettingsButton({
-  subscription,
-}: {
-  subscription: ManagedGroupSubscription
-}) {
+function AvailableWithGroupProfessionalBadge() {
   const { t } = useTranslation()
+  const location = useLocation()
 
+  const handleUpgradeClick = () => {
+    location.assign('/user/subscription/group/upgrade-subscription')
+  }
+
+  return (
+    <OLTag
+      prepend={
+        <img
+          aria-hidden="true"
+          src="/img/material-icons/star-gradient.svg"
+          alt=""
+        />
+      }
+      contentProps={{
+        className: bsVersion({ bs5: 'mw-100' }),
+        onClick: handleUpgradeClick,
+      }}
+    >
+      <strong>{t('available_with_group_professional')}</strong>
+    </OLTag>
+  )
+}
+
+function useGroupSettingsButton(subscription: ManagedGroupSubscription) {
+  const { t } = useTranslation()
+  const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing')
   const subscriptionHasManagedUsers =
     subscription.features?.managedUsers === true
   const subscriptionHasGroupSSO = subscription.features?.groupSSO === true
+  const heading = isFlexibleGroupLicensing
+    ? t('group_settings')
+    : t('manage_group_settings')
 
   let groupSettingRowSubText = ''
   if (subscriptionHasGroupSSO && subscriptionHasManagedUsers) {
@@ -22,12 +54,68 @@ export default function GroupSettingsButton({
     groupSettingRowSubText = t('manage_group_settings_subtext_managed_users')
   }
 
+  return {
+    heading,
+    groupSettingRowSubText,
+  }
+}
+
+export function GroupSettingsButton({
+  subscription,
+}: {
+  subscription: ManagedGroupSubscription
+}) {
+  const { heading, groupSettingRowSubText } =
+    useGroupSettingsButton(subscription)
+
   return (
     <RowLink
       href={`/manage/groups/${subscription._id}/settings`}
-      heading={t('manage_group_settings')}
+      heading={heading}
       subtext={groupSettingRowSubText}
       icon="settings"
     />
   )
 }
+
+export function GroupSettingsButtonWithAdBadge({
+  subscription,
+}: {
+  subscription: ManagedGroupSubscription
+}) {
+  const { heading, groupSettingRowSubText } =
+    useGroupSettingsButton(subscription)
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <div className="row-link text-muted">
+          <div className="icon">
+            <MaterialIcon type="settings" />
+          </div>
+          <div className="text">
+            <div className="heading">{heading}</div>
+            <div className="subtext">{groupSettingRowSubText}</div>
+          </div>
+          <span className="badge-group-settings">
+            <AvailableWithGroupProfessionalBadge />
+          </span>
+        </div>
+      }
+      bs5={
+        <li className="list-group-item row-link">
+          <div className="row-link-inner">
+            <MaterialIcon type="settings" className="p-2 p-md-3 text-muted" />
+            <div className="flex-grow-1 text-truncate text-muted">
+              <strong>{heading}</strong>
+              <div className="text-truncate">{groupSettingRowSubText}</div>
+            </div>
+            <span className="p-2 p-md-3">
+              <AvailableWithGroupProfessionalBadge />
+            </span>
+          </div>
+        </li>
+      }
+    />
+  )
+}

+ 31 - 4
services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx

@@ -1,9 +1,15 @@
-import GroupSettingsButton from '@/features/subscription/components/dashboard/group-settings-button'
+import {
+  GroupSettingsButton,
+  GroupSettingsButtonWithAdBadge,
+} from '@/features/subscription/components/dashboard/group-settings-button'
 import getMeta from '@/utils/meta'
 import { Trans, useTranslation } from 'react-i18next'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 import { RowLink } from './row-link'
 import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+import classnames from 'classnames'
 
 function ManagedGroupAdministrator({
   subscription,
@@ -85,11 +91,14 @@ function ManagedGroupAdministrator({
 export default function ManagedGroupSubscriptions() {
   const { t } = useTranslation()
   const { managedGroupSubscriptions } = useSubscriptionDashboardContext()
+  const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing')
 
   if (!managedGroupSubscriptions) {
     return null
   }
 
+  const groupSettingsAdvertisedFor =
+    getMeta('ol-groupSettingsAdvertisedFor') || []
   const groupSettingsEnabledFor = getMeta('ol-groupSettingsEnabledFor') || []
 
   return (
@@ -97,28 +106,46 @@ export default function ManagedGroupSubscriptions() {
       {managedGroupSubscriptions.map(subscription => {
         return (
           <div key={`managed-group-${subscription._id}`}>
+            <h2 className={classnames('h3', bsVersion({ bs5: 'fw-bold' }))}>
+              {t('group_management')}
+            </h2>
             <p>
               <ManagedGroupAdministrator subscription={subscription} />
             </p>
             <ul className="list-group p-0">
               <RowLink
                 href={`/manage/groups/${subscription._id}/members`}
-                heading={t('manage_members')}
+                heading={
+                  isFlexibleGroupLicensing
+                    ? t('group_members')
+                    : t('manage_members')
+                }
                 subtext={t('manage_group_members_subtext')}
                 icon="groups"
               />
               <RowLink
                 href={`/manage/groups/${subscription._id}/managers`}
-                heading={t('manage_group_managers')}
+                heading={
+                  isFlexibleGroupLicensing
+                    ? t('group_managers')
+                    : t('manage_group_managers')
+                }
                 subtext={t('manage_managers_subtext')}
                 icon="manage_accounts"
               />
               {groupSettingsEnabledFor?.includes(subscription._id) && (
                 <GroupSettingsButton subscription={subscription} />
               )}
+              {groupSettingsAdvertisedFor?.includes(subscription._id) && (
+                <GroupSettingsButtonWithAdBadge subscription={subscription} />
+              )}
               <RowLink
                 href={`/metrics/groups/${subscription._id}`}
-                heading={t('view_metrics')}
+                heading={
+                  isFlexibleGroupLicensing
+                    ? t('usage_metrics')
+                    : t('view_metrics')
+                }
                 subtext={t('view_metrics_group_subtext')}
                 icon="insights"
               />

+ 7 - 1
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx

@@ -3,12 +3,14 @@ import { RecurlySubscription } from '../../../../../../types/subscription/dashbo
 import { ActiveSubscription } from './states/active/active'
 import { ActiveAiAddonSubscription } from './states/active/active-ai-addon'
 import { PausedSubscription } from './states/active/paused'
+import { ActiveSubscriptionNew } from '@/features/subscription/components/dashboard/states/active/active-new'
 import { CanceledSubscription } from './states/canceled'
 import { ExpiredSubscription } from './states/expired'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email'
 import OLNotification from '@/features/ui/components/ol/ol-notification'
 import { isStandaloneAiPlanCode, AI_ADD_ON_CODE } from '../../data/add-on-codes'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 function PastDueSubscriptionAlert({
   subscription,
@@ -42,6 +44,7 @@ function PersonalSubscriptionStates({
 }) {
   const { t } = useTranslation()
   const state = subscription?.recurly.state
+  const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing')
 
   const hasAiAddon = subscription?.addOns?.some(
     addOn => addOn.addOnCode === AI_ADD_ON_CODE
@@ -50,7 +53,10 @@ function PersonalSubscriptionStates({
   const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
   const planHasAi = onAiStandalonePlan || hasAiAddon
 
-  if (state === 'active' && planHasAi) {
+  if (state === 'active' && isFlexibleGroupLicensing) {
+    // This version handles subscriptions with and without addons
+    return <ActiveSubscriptionNew subscription={subscription} />
+  } else if (state === 'active' && planHasAi) {
     return <ActiveAiAddonSubscription subscription={subscription} />
   } else if (state === 'active') {
     return <ActiveSubscription subscription={subscription} />

+ 19 - 9
services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx

@@ -1,18 +1,28 @@
 import { Trans } from 'react-i18next'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 function PremiumFeaturesLink() {
-  const featuresPageLink = (
-    // translation adds content
-    // eslint-disable-next-line jsx-a11y/anchor-has-content
-    <a href="/about/features-overview" />
-  )
+  const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing')
 
   return (
     <p>
-      <Trans
-        i18nKey="get_most_subscription_by_checking_features"
-        components={[featuresPageLink]}
-      />
+      {isFlexibleGroupLicensing ? (
+        <Trans
+          i18nKey="get_most_subscription_discover_premium_features"
+          components={[
+            // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
+            <a href="/learn/how-to/Overleaf_premium_features" />,
+          ]}
+        />
+      ) : (
+        <Trans
+          i18nKey="get_most_subscription_by_checking_features"
+          components={[
+            // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
+            <a href="/about/features-overview" />,
+          ]}
+        />
+      )}
     </p>
   )
 }

+ 1 - 1
services/web/frontend/js/features/subscription/components/dashboard/row-link.tsx

@@ -32,7 +32,7 @@ function BS3RowLink({ href, heading, subtext, icon }: RowLinkProps) {
 function BS5RowLink({ href, heading, subtext, icon }: RowLinkProps) {
   return (
     <li className="list-group-item row-link">
-      <a href={href}>
+      <a href={href} className="row-link-inner">
         <MaterialIcon type={icon} className="p-2 p-md-3" />
         <div className="flex-grow-1">
           <strong>{heading}</strong>

+ 297 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx

@@ -0,0 +1,297 @@
+import { useTranslation, Trans } from 'react-i18next'
+import { PriceExceptions } from '../../../shared/price-exceptions'
+import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
+import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { CancelSubscriptionButton } from './cancel-subscription-button'
+import { CancelSubscription } from './cancel-plan/cancel-subscription'
+import { PendingPlanChange } from './pending-plan-change'
+import { TrialEnding } from './trial-ending'
+import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
+import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
+import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
+import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
+import { CancelAiAddOnModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import isInFreeTrial from '../../../../util/is-in-free-trial'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import AddOns from '@/features/subscription/components/dashboard/states/active/add-ons'
+import {
+  AI_ADD_ON_CODE,
+  AI_STANDALONE_PLAN_CODE,
+  isStandaloneAiPlanCode,
+} from '@/features/subscription/data/add-on-codes'
+import getMeta from '@/utils/meta'
+import classnames from 'classnames'
+import SubscriptionRemainder from '@/features/subscription/components/dashboard/states/active/subscription-remainder'
+
+export function ActiveSubscriptionNew({
+  subscription,
+}: {
+  subscription: RecurlySubscription
+}) {
+  const { t } = useTranslation()
+  const {
+    recurlyLoadError,
+    setModalIdShown,
+    showCancellation,
+    institutionMemberships,
+    memberGroupSubscriptions,
+  } = useSubscriptionDashboardContext()
+
+  if (showCancellation) return <CancelSubscription />
+
+  const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
+
+  let planName
+  if (onStandalonePlan) {
+    planName = 'Overleaf Free'
+    if (institutionMemberships && institutionMemberships.length > 0) {
+      planName = 'Overleaf Professional'
+    }
+    if (memberGroupSubscriptions.length > 0) {
+      if (
+        memberGroupSubscriptions.some(s => s.planLevelName === 'Professional')
+      ) {
+        planName = 'Overleaf Professional'
+      } else {
+        planName = 'Overleaf Standard'
+      }
+    }
+  } else {
+    planName = subscription.plan.name
+  }
+
+  const handlePlanChange = () => setModalIdShown('change-plan')
+  const handleCancelClick = (addOnCode: string) => {
+    if ([AI_STANDALONE_PLAN_CODE, AI_ADD_ON_CODE].includes(addOnCode)) {
+      setModalIdShown('cancel-ai-add-on')
+    }
+  }
+
+  const isLegacyPlan =
+    subscription.recurly.totalLicenses !==
+    subscription.recurly.additionalLicenses
+
+  return (
+    <>
+      <h2 className={classnames('h3', bsVersion({ bs5: 'fw-bold' }))}>
+        {t('billing')}
+      </h2>
+      <p className="mb-1">
+        {subscription.plan.annual ? (
+          <Trans
+            i18nKey="billed_annually_at"
+            values={{ price: subscription.recurly.displayPrice }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+              // eslint-disable-next-line react/jsx-key
+              <i />,
+            ]}
+          />
+        ) : (
+          <Trans
+            i18nKey="billed_monthly_at"
+            values={{ price: subscription.recurly.displayPrice }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+              // eslint-disable-next-line react/jsx-key
+              <i />,
+            ]}
+          />
+        )}
+      </p>
+      <p className="mb-1">
+        <Trans
+          i18nKey="renews_on"
+          values={{ date: subscription.recurly.nextPaymentDueDate }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[<strong />]} // eslint-disable-line react/jsx-key
+        />
+      </p>
+      <div>
+        <a
+          href={subscription.recurly.accountManagementLink}
+          target="_blank"
+          rel="noreferrer noopener"
+          className="me-2"
+        >
+          {t('view_invoices')}
+        </a>
+        <a
+          href={subscription.recurly.billingDetailsLink}
+          target="_blank"
+          rel="noreferrer noopener"
+        >
+          {t('view_billing_details')}
+        </a>
+      </div>
+      <div className="mt-3">
+        <PriceExceptions subscription={subscription} />
+        {!recurlyLoadError && (
+          <p>
+            <i>
+              <SubscriptionRemainder subscription={subscription} hideTime />
+            </i>
+          </p>
+        )}
+      </div>
+      <hr />
+      <h2 className={classnames('h3', bsVersion({ bs5: 'fw-bold' }))}>
+        {t('plan')}
+      </h2>
+      <h3 className={classnames('h5 mt-0 mb-1', bsVersion({ bs5: 'fw-bold' }))}>
+        {planName}
+      </h3>
+      <p className="mb-1">
+        {subscription.pendingPlan && (
+          <>
+            {' '}
+            <PendingPlanChange subscription={subscription} />
+          </>
+        )}
+      </p>
+      {subscription.pendingPlan &&
+        subscription.pendingPlan.name !== subscription.plan.name && (
+          <p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
+        )}
+      {isInFreeTrial(subscription.recurly.trial_ends_at) &&
+        subscription.recurly.trialEndsAtFormatted && (
+          <TrialEnding
+            trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
+            className="mb-1"
+          />
+        )}
+      {!subscription.pendingPlan && subscription.recurly.totalLicenses > 0 && (
+        <p className="mb-1">
+          {isLegacyPlan && subscription.recurly.additionalLicenses > 0 ? (
+            <Trans
+              i18nKey="supports_up_to_x_users_incl_y_additional_licenses"
+              values={{
+                count: subscription.recurly.totalLicenses,
+                additionalLicenses: subscription.recurly.additionalLicenses,
+              }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+              components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
+            />
+          ) : (
+            <Trans
+              i18nKey="supports_up_to_x_users"
+              values={{ count: subscription.recurly.totalLicenses }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+              components={[<strong />]} // eslint-disable-line react/jsx-key
+            />
+          )}
+        </p>
+      )}
+      {!onStandalonePlan && (
+        <p className="mb-1">
+          {subscription.plan.annual
+            ? t('x_price_per_year', {
+                price: subscription.recurly.planOnlyDisplayPrice,
+              })
+            : t('x_price_per_month', {
+                price: subscription.recurly.planOnlyDisplayPrice,
+              })}
+        </p>
+      )}
+      {!recurlyLoadError && (
+        <PlanActions
+          subscription={subscription}
+          onStandalonePlan={onStandalonePlan}
+          handlePlanChange={handlePlanChange}
+        />
+      )}
+      <hr />
+      <AddOns
+        subscription={subscription}
+        onStandalonePlan={onStandalonePlan}
+        handleCancelClick={handleCancelClick}
+      />
+
+      <ChangePlanModal />
+      <ConfirmChangePlanModal />
+      <KeepCurrentPlanModal />
+      <ChangeToGroupModal />
+      <CancelAiAddOnModal />
+    </>
+  )
+}
+
+type PlanActionsProps = {
+  subscription: RecurlySubscription
+  onStandalonePlan: boolean
+  handlePlanChange: () => void
+}
+
+function PlanActions({
+  subscription,
+  onStandalonePlan,
+  handlePlanChange,
+}: PlanActionsProps) {
+  const { t } = useTranslation()
+  const isSubscriptionEligibleForFlexibleGroupLicensing = getMeta(
+    'ol-canUseFlexibleLicensing'
+  )
+
+  return (
+    <div className="mt-3">
+      {isSubscriptionEligibleForFlexibleGroupLicensing ? (
+        <FlexibleGroupLicensingActions subscription={subscription} />
+      ) : (
+        <>
+          {subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
+            <OLButton variant="secondary" onClick={handlePlanChange}>
+              {t('upgrade_plan')}
+            </OLButton>
+          )}
+        </>
+      )}
+      {!onStandalonePlan && (
+        <>
+          {' '}
+          <CancelSubscriptionButton />
+        </>
+      )}
+    </div>
+  )
+}
+
+function FlexibleGroupLicensingActions({
+  subscription,
+}: {
+  subscription: RecurlySubscription
+}) {
+  const { t } = useTranslation()
+  const isProfessionalPlan = subscription.planCode
+    .toLowerCase()
+    .includes('professional')
+
+  return (
+    <>
+      {!isProfessionalPlan && (
+        <>
+          <OLButton
+            variant="secondary"
+            href="/user/subscription/group/upgrade-subscription"
+          >
+            {t('upgrade_plan')}
+          </OLButton>{' '}
+        </>
+      )}
+      {subscription.plan.membersLimitAddOn === 'additional-license' && (
+        <OLButton variant="secondary" href="/user/subscription/group/add-users">
+          {t('add_more_users')}
+        </OLButton>
+      )}
+    </>
+  )
+}

+ 144 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx

@@ -0,0 +1,144 @@
+import { useTranslation } from 'react-i18next'
+import {
+  Dropdown as BS3Dropdown,
+  MenuItem as BS3MenuItem,
+} from 'react-bootstrap'
+import { Dropdown, DropdownMenu, DropdownToggle } from 'react-bootstrap-5'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
+import ControlledDropdown from '@/shared/components/controlled-dropdown'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
+import {
+  ADD_ON_NAME,
+  AI_ADD_ON_CODE,
+  AI_STANDALONE_ANNUAL_PLAN_CODE,
+  AI_STANDALONE_PLAN_CODE,
+} from '@/features/subscription/data/add-on-codes'
+import sparkle from '@/shared/svgs/sparkle.svg'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import classnames from 'classnames'
+import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PendingRecurlyPlan } from '../../../../../../../../types/subscription/plan'
+
+type AddOnsProps = {
+  subscription: RecurlySubscription
+  onStandalonePlan: boolean
+  handleCancelClick: (code: string) => void
+}
+
+function AddOns({
+  subscription,
+  onStandalonePlan,
+  handleCancelClick,
+}: AddOnsProps) {
+  const { t } = useTranslation()
+  const addOnsDisplayPrices =
+    subscription.recurly.addOnDisplayPricesWithoutAdditionalLicense
+  const addOnsDisplayPricesEntries = Object.entries(
+    onStandalonePlan
+      ? {
+          [AI_STANDALONE_PLAN_CODE]: subscription.recurly.displayPrice,
+        }
+      : addOnsDisplayPrices
+  )
+  const pendingPlan = subscription.pendingPlan as PendingRecurlyPlan
+  const hasAiAddon = subscription.addOns?.some(
+    addOn => addOn.addOnCode === AI_ADD_ON_CODE
+  )
+  const pendingCancellation = Boolean(
+    hasAiAddon &&
+      pendingPlan &&
+      !pendingPlan.addOns?.some(addOn => addOn.add_on_code === AI_ADD_ON_CODE)
+  )
+
+  const resolveAddOnName = (addOnCode: string) => {
+    switch (addOnCode) {
+      case AI_ADD_ON_CODE:
+      case AI_STANDALONE_ANNUAL_PLAN_CODE:
+      case AI_STANDALONE_PLAN_CODE:
+        return ADD_ON_NAME
+    }
+  }
+
+  return (
+    <>
+      <h2 className={classnames('h3', bsVersion({ bs5: 'fw-bold' }))}>
+        {t('add_ons')}
+      </h2>
+      {addOnsDisplayPricesEntries.length > 0 ? (
+        addOnsDisplayPricesEntries.map(([code, displayPrice]) => (
+          <div className="add-on-card" key={code}>
+            <div>
+              <img
+                alt="sparkle"
+                className="add-on-card-icon"
+                src={sparkle}
+                aria-hidden="true"
+              />
+            </div>
+            <div className="add-on-card-content">
+              <div className="heading">{resolveAddOnName(code)}</div>
+              <div className="description small mt-1">
+                {subscription.plan.annual
+                  ? t('x_price_per_year', { price: displayPrice })
+                  : t('x_price_per_month', { price: displayPrice })}
+              </div>
+            </div>
+            {!pendingCancellation && (
+              <div className="ms-auto">
+                <BootstrapVersionSwitcher
+                  bs3={
+                    <ControlledDropdown id="add-ons-actions" pullRight>
+                      <BS3Dropdown.Toggle
+                        noCaret
+                        bsStyle={null}
+                        className="add-on-options-toggle btn-secondary"
+                      >
+                        <MaterialIcon
+                          type="more_vert"
+                          accessibilityLabel={t('more_options')}
+                        />
+                      </BS3Dropdown.Toggle>
+                      <BS3Dropdown.Menu>
+                        <BS3MenuItem onClick={() => handleCancelClick(code)}>
+                          {t('cancel')}
+                        </BS3MenuItem>
+                      </BS3Dropdown.Menu>
+                    </ControlledDropdown>
+                  }
+                  bs5={
+                    <Dropdown align="end">
+                      <DropdownToggle
+                        id="add-on-dropdown-toggle"
+                        className="add-on-options-toggle"
+                        variant="secondary"
+                      >
+                        <MaterialIcon
+                          type="more_vert"
+                          accessibilityLabel={t('more_options')}
+                        />
+                      </DropdownToggle>
+                      <DropdownMenu flip={false}>
+                        <OLDropdownMenuItem
+                          onClick={() => handleCancelClick(code)}
+                          as="button"
+                          tabIndex={-1}
+                        >
+                          {t('cancel')}
+                        </OLDropdownMenuItem>
+                      </DropdownMenu>
+                    </Dropdown>
+                  }
+                />
+              </div>
+            )}
+          </div>
+        ))
+      ) : (
+        <p>{t('you_dont_have_any_add_ons_on_your_account')}</p>
+      )}
+    </>
+  )
+}
+
+export default AddOns

+ 8 - 4
services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx

@@ -1,12 +1,16 @@
 import { Trans } from 'react-i18next'
 
+type TrialEndingProps = {
+  trialEndsAtFormatted: string
+  className?: string
+}
+
 export function TrialEnding({
   trialEndsAtFormatted,
-}: {
-  trialEndsAtFormatted: string
-}) {
+  className,
+}: TrialEndingProps) {
   return (
-    <p>
+    <p className={className}>
       <Trans
         i18nKey="youre_on_free_trial_which_ends_on"
         values={{ date: trialEndsAtFormatted }}

+ 4 - 3
services/web/frontend/js/features/subscription/components/dashboard/states/canceled.tsx

@@ -1,6 +1,7 @@
 import { useTranslation, Trans } from 'react-i18next'
 import { RecurlySubscription } from '../../../../../../../types/subscription/dashboard/subscription'
 import ReactivateSubscription from '../reactivate-subscription'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 export function CanceledSubscription({
   subscription,
@@ -40,14 +41,14 @@ export function CanceledSubscription({
         />
       </p>
       <p>
-        <a
+        <OLButton
           href={subscription.recurly.accountManagementLink}
           target="_blank"
+          variant="secondary"
           rel="noopener noreferrer"
-          className="btn btn-secondary-info btn-secondary"
         >
           {t('view_your_invoices')}
-        </a>
+        </OLButton>
       </p>
       <ReactivateSubscription />
     </>

+ 7 - 5
services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx

@@ -1,5 +1,6 @@
 import { useTranslation } from 'react-i18next'
 import { RecurlySubscription } from '../../../../../../../types/subscription/dashboard/subscription'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 export function ExpiredSubscription({
   subscription,
@@ -12,17 +13,18 @@ export function ExpiredSubscription({
     <>
       <p>{t('your_subscription_has_expired')}</p>
       <p>
-        <a
+        <OLButton
           href={subscription.recurly.accountManagementLink}
-          className="btn btn-secondary-info btn-secondary me-1"
           target="_blank"
           rel="noreferrer noopener"
+          variant="secondary"
+          className="me-1"
         >
           {t('view_your_invoices')}
-        </a>
-        <a href="/user/subscription/plans" className="btn btn-primary">
+        </OLButton>
+        <OLButton href="/user/subscription/plans" variant="primary">
           {t('create_new_subscription')}
-        </a>
+        </OLButton>
       </p>
     </>
   )

+ 8 - 2
services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx

@@ -38,13 +38,19 @@ const Tag = forwardRef<HTMLElement, TagProps>(
         {contentProps?.onClick ? (
           <button
             type="button"
-            className="badge-tag-content badge-tag-content-btn"
             {...contentProps}
+            className={classnames(
+              'badge-tag-content badge-tag-content-btn',
+              contentProps.className
+            )}
           >
             {content}
           </button>
         ) : (
-          <span className="badge-tag-content" {...contentProps}>
+          <span
+            {...contentProps}
+            className={classnames('badge-tag-content', contentProps?.className)}
+          >
             {content}
           </span>
         )}

+ 1 - 0
services/web/frontend/js/features/ui/components/ol/ol-tag.tsx

@@ -21,6 +21,7 @@ const OLTag = forwardRef<HTMLElement, OLTagProps>((props: OLTagProps, ref) => {
     onBlur: rest.onBlur,
     onMouseOver: rest.onMouseOver,
     onMouseOut: rest.onMouseOut,
+    contentProps: rest.contentProps,
     ...getAriaAndDataProps(rest),
     ...bs3Props,
   }

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

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

+ 8 - 1
services/web/frontend/js/shared/components/tag.tsx

@@ -28,7 +28,14 @@ function Tag({
 
   return (
     <span className={classnames('badge-tag-bs3', className)} {...rest}>
-      <span className="badge-tag-bs3-content-wrapper">
+      <span
+        {...contentProps}
+        className={classnames(
+          'badge-tag-bs3-content-wrapper',
+          { clickable: Boolean(contentProps?.onClick) },
+          contentProps?.className
+        )}
+      >
         {prepend && <span className="badge-tag-bs3-prepend">{prepend}</span>}
         <span className="badge-tag-bs3-content">{children}</span>
       </span>

+ 1 - 0
services/web/frontend/js/shared/svgs/sparkle.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="none"><g clip-path="url(#a)"><path fill="url(#b)" d="M29.7 16.9c0-.2-.3-.2-.4-.3-.9 0-8.1-.3-8.1-8.7 0-.3-.2-.5-.5-.5-.2 0-.5.2-.5.5a8 8 0 0 1-8 8.7l-.3.1-.2.2v.5l.5.2c.9 0 8 .3 8 8.7 0 .3.3.5.5.5.3 0 .5-.2.5-.5a8 8 0 0 1 8-8.7c.3 0 .4 0 .5-.2v-.5Z"/><path fill="url(#c)" d="M10.4 22.9c-.4 0-4.3-.2-4.3-4.8 0-.2-.3-.4-.5-.4-.3 0-.5.2-.5.4 0 4.6-4 4.8-4.4 4.8-.3 0-.5.2-.5.5 0 .2.2.4.5.4.5 0 4.4.2 4.4 4.8 0 .3.2.5.5.5.2 0 .4-.2.4-.5C6 24 10 24 10.4 24c.3 0 .5-.2.5-.5s-.2-.5-.5-.5Z"/><path fill="url(#d)" d="M14.7 7.4c.2 0 .4-.3.4-.6 0-.2-.2-.4-.4-.4-.5 0-4.4-.2-4.4-4.8 0-.3-.3-.5-.5-.5-.3 0-.5.2-.5.5 0 4.6-4 4.8-4.4 4.8-.3 0-.5.2-.5.4 0 .3.3.5.5.5.5 0 4.4.2 4.4 4.8 0 .2.2.5.5.5.2 0 .4-.2.4-.5 0-4.6 4-4.7 4.4-4.7Z"/></g><defs><linearGradient id="b" x1="29.8" x2="8.2" y1="7.4" y2="16.6" gradientUnits="userSpaceOnUse"><stop stop-color="#214475"/><stop offset=".3" stop-color="#254C84"/><stop offset="1" stop-color="#6597E0"/></linearGradient><linearGradient id="c" x1="10.9" x2="-1.8" y1="17.7" y2="23" gradientUnits="userSpaceOnUse"><stop stop-color="#214475"/><stop offset=".3" stop-color="#254C84"/><stop offset="1" stop-color="#6597E0"/></linearGradient><linearGradient id="d" x1="15.1" x2="2.4" y1="1.1" y2="6.5" gradientUnits="userSpaceOnUse"><stop stop-color="#214475"/><stop offset=".3" stop-color="#254C84"/><stop offset="1" stop-color="#6597E0"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 .9h30v28.2H0z"/></clipPath></defs></svg>

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

@@ -104,6 +104,7 @@ export interface Meta {
   'ol-groupPolicy': GroupPolicy
   'ol-groupSSOActive': boolean
   'ol-groupSSOTestResult': GroupSSOTestResult
+  'ol-groupSettingsAdvertisedFor': string[]
   'ol-groupSettingsEnabledFor': string[]
   'ol-groupSize': number
   'ol-groupSsoSetupSuccess': boolean

+ 60 - 5
services/web/frontend/stylesheets/app/subscription.less

@@ -231,17 +231,21 @@
   }
 }
 
-a.row-link {
+.row-link {
   line-height: 24px;
-  color: @neutral-70;
+  color: @neutral-70 !important;
   display: flex;
   flex-direction: row;
   text-decoration: none;
   padding: 6px 0;
 
-  &:active,
-  &:focus,
-  &:hover {
+  &.text-muted {
+    color: @neutral-40 !important;
+  }
+
+  a&:active,
+  a&:focus,
+  a&:hover {
     text-decoration: none;
     outline: none;
     background-color: @gray-lightest;
@@ -268,12 +272,15 @@ a.row-link {
     flex: 1 1 90%;
     display: flex;
     flex-direction: column;
+    .text-truncate;
 
     .heading {
       font-weight: @btn-font-weight;
+      .text-truncate;
     }
     .subtext {
       font-weight: 400;
+      .text-truncate;
     }
   }
 }
@@ -306,3 +313,51 @@ a.row-link {
   margin-top: 0;
   color: @content-primary-on-dark-bg;
 }
+
+.add-on-card {
+  display: flex;
+  align-items: center;
+  padding: var(--spacing-05);
+  gap: var(--spacing-05);
+  border: 1px solid var(--neutral-20);
+  border-radius: var(--border-radius-base-new);
+
+  .add-on-card-icon {
+    width: 40px;
+    height: 40px;
+  }
+
+  .add-on-card-content {
+    display: flex;
+    flex-direction: column;
+
+    .small {
+      .body-sm;
+    }
+  }
+
+  .heading {
+    .body-base;
+    font-weight: 600;
+  }
+
+  .description {
+    color: @content-secondary;
+  }
+
+  .highlight {
+    color: @content-primary;
+  }
+
+  .add-on-options-toggle {
+    padding: var(--spacing-04);
+    font-size: 0;
+    line-height: 1;
+    border: none;
+    border-radius: 50%;
+
+    &::after {
+      content: none;
+    }
+  }
+}

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

@@ -78,6 +78,11 @@ $max-width: 160px;
   padding-right: var(--bs-badge-padding-x);
   border-top-left-radius: inherit;
   border-bottom-left-radius: inherit;
+
+  &:last-child {
+    border-top-right-radius: inherit;
+    border-bottom-right-radius: inherit;
+  }
 }
 
 .badge-tag-content-btn {

+ 48 - 1
services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss

@@ -15,7 +15,7 @@
     border: 0;
     padding: 0;
 
-    a {
+    .row-link-inner {
       display: flex;
       justify-content: space-between;
       align-items: center;
@@ -23,7 +23,9 @@
       text-decoration: none;
       color: var(--neutral-90);
       width: 100%;
+    }
 
+    a.row-link-inner {
       &:hover {
         background-color: var(--neutral-10);
       }
@@ -437,3 +439,48 @@
     }
   }
 }
+
+.add-on-card {
+  display: flex;
+  align-items: center;
+  padding: var(--spacing-05);
+  gap: var(--spacing-05);
+  border: 1px solid var(--border-divider);
+  border-radius: var(--border-radius-base);
+
+  .add-on-card-icon {
+    width: 40px;
+    height: 40px;
+  }
+
+  .add-on-card-content {
+    display: flex;
+    flex-direction: column;
+  }
+
+  .heading {
+    @include body-base;
+
+    font-weight: 600;
+  }
+
+  .description {
+    color: var(--content-secondary);
+  }
+
+  .highlight {
+    color: var(--content-primary);
+  }
+
+  .add-on-options-toggle {
+    padding: var(--spacing-04);
+    font-size: 0;
+    line-height: 1;
+    border: none;
+    border-radius: 50%;
+
+    &::after {
+      content: none;
+    }
+  }
+}

+ 1 - 0
services/web/frontend/stylesheets/components/badge.less

@@ -27,6 +27,7 @@
 
   &-prepend {
     margin-right: 2px;
+    display: flex;
   }
 
   &-close {

+ 5 - 0
services/web/frontend/stylesheets/components/group-members.less

@@ -204,3 +204,8 @@
     top: 4px;
   }
 }
+
+.badge-group-settings {
+  align-self: center;
+  padding: 0 16px;
+}

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

@@ -92,6 +92,7 @@
   "add_more_users_to_my_plan": "Add more users to my plan",
   "add_new_email": "Add new email",
   "add_on": "Add-on",
+  "add_ons": "Add-ons",
   "add_ons_are": "<strong>Add-ons:</strong> __addOnName__",
   "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
   "add_people": "Add people",
@@ -194,6 +195,7 @@
   "autocomplete": "Autocomplete",
   "autocomplete_references": "Reference Autocomplete (inside a <code>\\cite{}</code> block)",
   "automatic_user_registration_uppercase": "Automatic user registration",
+  "available_with_group_professional": "Available with Group Professional",
   "back": "Back",
   "back_to_account_settings": "Back to account settings",
   "back_to_all_posts": "Back to all posts",
@@ -216,7 +218,10 @@
   "beta_program_opt_out_action": "Opt-Out of Beta Program",
   "bibliographies": "Bibliographies",
   "billed_annually": "billed annually",
+  "billed_annually_at": "Billed annually at <0>__price__</0> <1>(includes plan and any add-ons)</1>",
+  "billed_monthly_at": "Billed monthly at <0>__price__</0> <1>(includes plan and any add-ons)</1>",
   "billed_yearly": "billed yearly",
+  "billing": "Billing",
   "billing_period_sentence_case": "Billing period",
   "binary_history_error": "Preview not available for this file type",
   "blank_project": "Blank Project",
@@ -782,6 +787,7 @@
   "get_involved": "Get involved",
   "get_more_compile_time": "Get more compile time",
   "get_most_subscription_by_checking_features": "Get the most out of your __appName__ subscription by checking out <0>__appName__’s features</0>.",
+  "get_most_subscription_discover_premium_features": "Get the most from your __appName__ subscription. <0>Discover premium features</0>.",
   "get_symbol_palette": "Get Symbol Palette",
   "get_the_best_overleaf_experience": "Get the best Overleaf experience",
   "get_the_best_writing_experience": "Get the best writing experience",
@@ -857,11 +863,15 @@
   "group_invite_has_been_sent_to_email": "Group invite has been sent to <0>__email__</0>",
   "group_libraries": "Group Libraries",
   "group_managed_by_group_administrator": "User accounts in this group are managed by the group administrator.",
+  "group_management": "Group management",
+  "group_managers": "Group managers",
+  "group_members": "Group members",
   "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_professional": "Group Professional",
+  "group_settings": "Group settings",
   "group_sso_configuration_idp_metadata": "The information you provide here comes from your Identity Provider (IdP). This is often referred to as its <0>SAML metadata</0>. You can add this manually or click <1>Import IdP metadata</1> to import an XML file.",
   "group_sso_configure_service_provider_in_idp": "For some IdPs, you must configure Overleaf as a Service Provider to get the data you need to fill out this form. To do this, you will need to download the Overleaf metadata.",
   "group_sso_documentation_links": "Please see our <0>documentation</0> and <1>troubleshooting guide</1> for more help.",
@@ -1728,6 +1738,7 @@
   "rename": "Rename",
   "rename_project": "Rename Project",
   "renaming": "Renaming",
+  "renews_on": "Renews on <0>__date__</0>",
   "reopen": "Re-open",
   "reopen_comment_error_message": "There was an error reopening your comment. Please try again in a few moments.",
   "reopen_comment_error_title": "Reopen Comment Error",
@@ -2060,6 +2071,8 @@
   "suggestion_applied": "Suggestion applied",
   "support": "Support",
   "support_for_your_browser_is_ending_soon": "Support for your browser is ending soon",
+  "supports_up_to_x_users": "Supports up to <0>__count__ users</0>",
+  "supports_up_to_x_users_incl_y_additional_licenses": "Supports up to <0>__count__ users</0> (Incl. <1>__additionalLicenses__</1> additional license(s))",
   "sure_you_want_to_cancel_plan_change": "Are you sure you want to revert your scheduled plan change? You will remain subscribed to the <0>__planName__</0> plan.",
   "sure_you_want_to_change_plan": "Are you sure you want to change plan to <0>__planName__</0>?",
   "sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?",
@@ -2356,6 +2369,7 @@
   "upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
   "upgrade_my_plan": "Upgrade my plan",
   "upgrade_now": "Upgrade now",
+  "upgrade_plan": "Upgrade plan",
   "upgrade_summary": "Upgrade summary",
   "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:",
@@ -2369,6 +2383,7 @@
   "url_to_fetch_the_file_from": "URL to fetch the file from",
   "us_gov_banner_government_purchasing": "<0>Get __appName__ for US federal government. </0>Move faster through procurement with our tailored purchasing options. Talk to our government team.",
   "us_gov_banner_small_business_reseller": "<0>Easy procurement for US federal government. </0>We partner with small business resellers to help you buy Overleaf organizational plans. Talk to our government team.",
+  "usage_metrics": "Usage metrics",
   "use_a_different_password": "Please use a different password",
   "use_saml_metadata_to_configure_sso_with_idp": "Use the Overleaf SAML metadata to configure SSO with your Identity Provider.",
   "use_your_own_machine": "Use your own machine, with your own setup",
@@ -2405,6 +2420,7 @@
   "verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
   "view": "View",
   "view_all": "View All",
+  "view_billing_details": "View billing details",
   "view_code": "View code",
   "view_configuration": "View configuration",
   "view_group_members": "View group members",
@@ -2487,6 +2503,7 @@
   "x_price_for_first_month": "<0>__price__</0> for your first month",
   "x_price_for_first_year": "<0>__price__</0> for your first year",
   "x_price_for_y_months": "<0>__price__</0> for your first __discountMonths__ months",
+  "x_price_per_month": "__price__ per month",
   "x_price_per_user": "__price__ per user",
   "x_price_per_year": "__price__ per year",
   "year": "year",
@@ -2519,6 +2536,7 @@
   "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
   "you_cant_join_this_group_subscription": "You can’t join this group subscription",
   "you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO</0>.",
+  "you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.",
   "you_dont_have_any_repositories": "You don’t have any repositories",
   "you_have_0_free_suggestions_left": "You have 0 free suggestions left",
   "you_have_1_free_suggestion_left": "You have 1 free suggestion left",

+ 33 - 0
services/web/test/frontend/features/subscription/fixtures/subscriptions.ts

@@ -54,6 +54,9 @@ export const annualActiveSubscription: RecurlySubscription = {
       has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
     },
     displayPrice: '$199.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }
 
@@ -96,6 +99,9 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = {
       has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
     },
     displayPrice: '€221.96',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }
 
@@ -137,6 +143,9 @@ export const annualActiveSubscriptionPro: RecurlySubscription = {
       has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
     },
     displayPrice: '$42.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }
 
@@ -179,6 +188,9 @@ export const pastDueExpiredSubscription: RecurlySubscription = {
       has_past_due_invoice: { _: 'true', $: { type: 'boolean' } },
     },
     displayPrice: '$199.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }
 
@@ -221,6 +233,9 @@ export const canceledSubscription: RecurlySubscription = {
       has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
     },
     displayPrice: '$199.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }
 
@@ -263,6 +278,9 @@ export const pendingSubscriptionChange: RecurlySubscription = {
       has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
     },
     displayPrice: '$199.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
   pendingPlan: {
     planCode: 'professional-annual',
@@ -316,6 +334,9 @@ export const groupActiveSubscription: GroupSubscription = {
       has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
     },
     displayPrice: '$1290.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }
 
@@ -376,6 +397,9 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
       currentPlanDisplayPrice: '$2709.00',
       pendingAdditionalLicenses: 13,
       pendingTotalLicenses: 23,
+      planOnlyDisplayPrice: '',
+      addOns: [],
+      addOnDisplayPricesWithoutAdditionalLicense: {},
     },
     pendingPlan: {
       planCode: 'group_collaborator_10_enterprise',
@@ -438,6 +462,9 @@ export const trialSubscription: RecurlySubscription = {
       },
     },
     displayPrice: '$14.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }
 
@@ -511,6 +538,9 @@ export const trialCollaboratorSubscription: RecurlySubscription = {
       },
     },
     displayPrice: '$21.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }
 
@@ -552,5 +582,8 @@ export const monthlyActiveCollaborator: RecurlySubscription = {
       has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
     },
     displayPrice: '$21.00',
+    planOnlyDisplayPrice: '',
+    addOns: [],
+    addOnDisplayPricesWithoutAdditionalLicense: {},
   },
 }

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

@@ -56,6 +56,7 @@ describe('SubscriptionGroupController', function () {
           .stub()
           .resolves(this.createSubscriptionChangeData),
         ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
+        ensureSubscriptionIsActive: sinon.stub().resolves(),
         getGroupPlanUpgradePreview: sinon
           .stub()
           .resolves(this.previewSubscriptionChangeData),
@@ -347,6 +348,9 @@ describe('SubscriptionGroupController', function () {
           this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
             .calledWith(this.plan)
             .should.equal(true)
+          this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
+            .calledWith(this.subscription)
+            .should.equal(true)
           page.should.equal('subscriptions/add-seats')
           props.subscriptionId.should.equal(this.subscriptionId)
           props.groupName.should.equal(this.subscription.teamName)
@@ -403,6 +407,21 @@ describe('SubscriptionGroupController', function () {
 
       this.Controller.addSeatsToGroupSubscription(this.req, res)
     })
+
+    it('should redirect to subscription page when subscription is not active', function (done) {
+      this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon
+        .stub()
+        .rejects()
+
+      const res = {
+        redirect: url => {
+          url.should.equal('/user/subscription')
+          done()
+        },
+      }
+
+      this.Controller.addSeatsToGroupSubscription(this.req, res)
+    })
   })
 
   describe('previewAddSeatsSubscriptionChange', function () {

+ 59 - 6
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -59,7 +59,12 @@ describe('SubscriptionGroupHandler', function () {
 
     this.SubscriptionLocator = {
       promises: {
-        getUsersSubscription: sinon.stub().resolves({ groupPlan: true }),
+        getUsersSubscription: sinon.stub().resolves({
+          groupPlan: true,
+          recurlyStatus: {
+            state: 'active',
+          },
+        }),
         getSubscriptionByMemberIdAndId: sinon.stub(),
         getSubscription: sinon.stub().resolves(this.subscription),
       },
@@ -303,7 +308,12 @@ describe('SubscriptionGroupHandler', function () {
 
       expect(data).to.deep.equal({
         userId: this.adminUser_id,
-        subscription: { groupPlan: true },
+        subscription: {
+          groupPlan: true,
+          recurlyStatus: {
+            state: 'active',
+          },
+        },
         plan: {
           membersLimit: 5,
           membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
@@ -517,11 +527,33 @@ describe('SubscriptionGroupHandler', function () {
     })
   })
 
+  describe('ensureSubscriptionIsActive', function () {
+    it('should throw if the subscription is not active', async function () {
+      await expect(
+        this.Handler.promises.ensureSubscriptionIsActive({})
+      ).to.be.rejectedWith('The subscription is not active')
+    })
+
+    it('should not throw if the subscription is active', async function () {
+      await expect(
+        this.Handler.promises.ensureSubscriptionIsActive({
+          recurlyStatus: { state: 'active' },
+        })
+      ).to.not.be.rejected
+    })
+  })
+
   describe('upgradeGroupPlan', function () {
     it('should upgrade the subscription for flexible licensing group plans', async function () {
       this.SubscriptionLocator.promises.getUsersSubscription = sinon
         .stub()
-        .resolves({ groupPlan: true, planCode: 'group_collaborator' })
+        .resolves({
+          groupPlan: true,
+          recurlyStatus: {
+            state: 'active',
+          },
+          planCode: 'group_collaborator',
+        })
       await this.Handler.promises.upgradeGroupPlan(this.user_id)
       this.recurlySubscription.getRequestForGroupPlanUpgrade
         .calledWith('group_professional')
@@ -539,6 +571,9 @@ describe('SubscriptionGroupHandler', function () {
         .stub()
         .resolves({
           groupPlan: true,
+          recurlyStatus: {
+            state: 'active',
+          },
           planCode: 'group_collaborator_10_educational',
         })
       await this.Handler.promises.upgradeGroupPlan(this.user_id)
@@ -556,7 +591,13 @@ describe('SubscriptionGroupHandler', function () {
     it('should fail the upgrade if is professional already', async function () {
       this.SubscriptionLocator.promises.getUsersSubscription = sinon
         .stub()
-        .resolves({ groupPlan: true, planCode: 'group_professional' })
+        .resolves({
+          groupPlan: true,
+          recurlyStatus: {
+            state: 'active',
+          },
+          planCode: 'group_professional',
+        })
       await expect(
         this.Handler.promises.upgradeGroupPlan(this.user_id)
       ).to.be.rejectedWith('Not eligible for group plan upgrade')
@@ -565,7 +606,13 @@ describe('SubscriptionGroupHandler', function () {
     it('should fail the upgrade if not group plan', async function () {
       this.SubscriptionLocator.promises.getUsersSubscription = sinon
         .stub()
-        .resolves({ groupPlan: false, planCode: 'test_plan_code' })
+        .resolves({
+          groupPlan: false,
+          recurlyStatus: {
+            state: 'active',
+          },
+          planCode: 'test_plan_code',
+        })
       await expect(
         this.Handler.promises.upgradeGroupPlan(this.user_id)
       ).to.be.rejectedWith('Not eligible for group plan upgrade')
@@ -576,7 +623,13 @@ describe('SubscriptionGroupHandler', function () {
     it('should generate preview for subscription upgrade', async function () {
       this.SubscriptionLocator.promises.getUsersSubscription = sinon
         .stub()
-        .resolves({ groupPlan: true, planCode: 'group_collaborator' })
+        .resolves({
+          groupPlan: true,
+          recurlyStatus: {
+            state: 'active',
+          },
+          planCode: 'group_collaborator',
+        })
       const result = await this.Handler.promises.getGroupPlanUpgradePreview(
         this.user_id
       )

+ 4 - 1
services/web/types/subscription/dashboard/subscription.ts

@@ -1,6 +1,6 @@
 import { CurrencyCode } from '../currency'
 import { Nullable } from '../../utils'
-import { Plan, AddOn } from '../plan'
+import { Plan, AddOn, RecurlyAddOn } from '../plan'
 import { User } from '../../user'
 
 type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
@@ -16,6 +16,7 @@ type Recurly = {
   billingDetailsLink: string
   accountManagementLink: string
   additionalLicenses: number
+  addOns: RecurlyAddOn[]
   totalLicenses: number
   nextPaymentDueAt: string
   nextPaymentDueDate: string
@@ -42,6 +43,8 @@ type Recurly = {
     }
   }
   displayPrice: string
+  planOnlyDisplayPrice: string
+  addOnDisplayPricesWithoutAdditionalLicense: Record<string, string>
   currentPlanDisplayPrice?: string
   pendingAdditionalLicenses?: number
   pendingTotalLicenses?: number

+ 1 - 0
services/web/types/subscription/plan.ts

@@ -27,6 +27,7 @@ export type RecurlyAddOn = {
   add_on_code: string
   quantity: number
   unit_amount_in_cents: number
+  displayPrice: string
 }
 
 export type PendingRecurlyPlan = {