Selaa lähdekoodia

Merge pull request #26934 from overleaf/ls-support-individual-to-group-plan-upgrade

Support individual to group plan upgrade in Stripe

GitOrigin-RevId: 24cbe7bd6de86a4d9410e1abc49b6457e0871f40
Liangjun Song 1 vuosi sitten
vanhempi
sitoutus
9e22ed9c3f

+ 47 - 31
services/web/app/src/Features/Subscription/PaymentProviderEntities.js

@@ -94,7 +94,7 @@ class PaymentProviderSubscription {
    * @return {boolean}
    */
   isGroupSubscription() {
-    return isGroupPlanCode(this.planCode)
+    return PlansLocator.isGroupPlanCode(this.planCode)
   }
 
   /**
@@ -118,33 +118,32 @@ class PaymentProviderSubscription {
 
   /**
    * Change this subscription's plan
-   *
+   * @param {string} planCode - the new plan code
+   * @param {number} [quantity] - the quantity of the plan
+   * @param {boolean} [shouldChangeAtTermEnd] - whether the change should be applied at the end of the term
    * @return {PaymentProviderSubscriptionChangeRequest}
    */
-  getRequestForPlanChange(planCode) {
-    const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode)
-    if (currentPlan == null) {
-      throw new OError('Unable to find plan in settings', {
-        planCode: this.planCode,
-      })
-    }
-    const newPlan = PlansLocator.findLocalPlanInSettings(planCode)
-    if (newPlan == null) {
-      throw new OError('Unable to find plan in settings', { planCode })
-    }
-    const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd)
-    const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
-      currentPlan,
-      newPlan,
-      isInTrial
-    )
-
+  getRequestForPlanChange(planCode, quantity, shouldChangeAtTermEnd) {
     const changeRequest = new PaymentProviderSubscriptionChangeRequest({
       subscription: this,
       timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
       planCode,
     })
 
+    if (quantity !== 1) {
+      // Only group plans in Stripe can have larger than 1 quantity
+      // This is because in Stripe, the group plans are configued with per-seat pricing
+      // and the quantity is the number of seats
+      // Setting the members limit add-on quantity accordingly
+      // so it is compitible with Recurly's group plan model (1 base plan + add-on for each member)
+      changeRequest.addOnUpdates = [
+        new PaymentProviderSubscriptionAddOnUpdate({
+          code: MEMBERS_LIMIT_ADD_ON_CODE,
+          quantity,
+        }),
+      ]
+    }
+
     // Carry the AI add-on to the new plan if applicable
     if (
       this.isStandaloneAiAddOn() ||
@@ -155,7 +154,9 @@ class PaymentProviderSubscription {
         code: AI_ADD_ON_CODE,
         quantity: 1,
       })
-      changeRequest.addOnUpdates = [addOnUpdate]
+      changeRequest.addOnUpdates = changeRequest.addOnUpdates
+        ? [...changeRequest.addOnUpdates, addOnUpdate]
+        : [addOnUpdate]
     }
 
     return changeRequest
@@ -360,6 +361,31 @@ class PaymentProviderSubscription {
   get isCollectionMethodManual() {
     return this.collectionMethod === 'manual'
   }
+
+  /**
+   * Determine if a plan change should be applied at the end of the term
+   *
+   * @param {string} newPlanCode
+   * @returns {boolean}
+   */
+  shouldPlanChangeAtTermEnd(newPlanCode) {
+    const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode)
+    if (currentPlan == null) {
+      throw new OError('Unable to find plan in settings', {
+        planCode: this.planCode,
+      })
+    }
+    const newPlan = PlansLocator.findLocalPlanInSettings(newPlanCode)
+    if (newPlan == null) {
+      throw new OError('Unable to find plan in settings', { newPlanCode })
+    }
+    const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd)
+    return SubscriptionHelper.shouldPlanChangeAtTermEnd(
+      currentPlan,
+      newPlan,
+      isInTrial
+    )
+  }
 }
 
 /**
@@ -584,15 +610,6 @@ class PaymentProviderAccount {
   }
 }
 
-/**
- * Returns whether the given plan code is a group plan
- *
- * @param {string} planCode
- */
-function isGroupPlanCode(planCode) {
-  return planCode.includes('group')
-}
-
 module.exports = {
   MEMBERS_LIMIT_ADD_ON_CODE,
   PaymentProviderSubscription,
@@ -607,6 +624,5 @@ module.exports = {
   PaymentProviderPlan,
   PaymentProviderCoupon,
   PaymentProviderAccount,
-  isGroupPlanCode,
   PaymentProviderImmediateCharge,
 }

+ 38 - 0
services/web/app/src/Features/Subscription/PlansLocator.js

@@ -137,9 +137,47 @@ function findLocalPlanInSettings(planCode) {
   return null
 }
 
+/**
+ * Returns whether the given plan code is a group plan
+ *
+ * @param {string} planCode
+ */
+function isGroupPlanCode(planCode) {
+  return planCode.includes('group')
+}
+
+/**
+ * Adapts a legacy Recurly group plan code (e.g., `group_professional_5_educational`)
+ * into its corresponding Stripe-compatible plan code (e.g., `group_professional_educational`),
+ * extracting the license quantity where applicable.
+ *
+ *  @param {RecurlyPlanCode} planCode
+ * @returns {{ planCode: RecurlyPlanCode, quantity: number }}
+ */
+function convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
+  planCode
+) {
+  const pattern =
+    /^group_(collaborator|professional)_(2|3|4|5|10|20|50)_(educational|enterprise)$/
+
+  const match = planCode.match(pattern)
+  if (match == null) {
+    return { planCode, quantity: 1 }
+  }
+
+  const [, tier, size, usage] = match
+  const newPlanCode = /** @type {RecurlyPlanCode} */ (
+    usage === 'enterprise' ? `group_${tier}` : `group_${tier}_${usage}`
+  )
+
+  return { planCode: newPlanCode, quantity: Number(size) }
+}
+
 module.exports = {
   ensurePlansAreSetupCorrectly,
   findLocalPlanInSettings,
   buildStripeLookupKey,
   getPlanTypeAndPeriodFromRecurlyPlanCode,
+  isGroupPlanCode,
+  convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded,
 }

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

@@ -425,6 +425,21 @@ async function subtotalLimitExceeded(req, res) {
   }
 }
 
+async function getGroupPlanPerUserPrices(req, res) {
+  try {
+    const userId = SessionManager.getLoggedInUserId(req.session)
+    const prices = await Modules.promises.hooks.fire(
+      'getGroupPlanPerUserPrices',
+      userId,
+      req.query.currency
+    )
+    return res.json(prices[0])
+  } catch (error) {
+    logger.err({ error }, 'error trying to get websale group product prices')
+    return res.sendStatus(500)
+  }
+}
+
 export default {
   removeUserFromGroup: expressify(removeUserFromGroup),
   removeSelfFromGroup: expressify(removeSelfFromGroup),
@@ -441,4 +456,5 @@ export default {
   missingBillingInformation: expressify(missingBillingInformation),
   manuallyCollectedSubscription: expressify(manuallyCollectedSubscription),
   subtotalLimitExceeded: expressify(subtotalLimitExceeded),
+  getGroupPlanPerUserPrices: expressify(getGroupPlanPerUserPrices),
 }

+ 7 - 0
services/web/app/src/Features/Subscription/SubscriptionRouter.mjs

@@ -106,6 +106,13 @@ export default {
       SubscriptionGroupController.subscriptionUpgradePage
     )
 
+    webRouter.get(
+      '/user/subscription/group/group-plan-per-user-prices',
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionGroupController.getGroupPlanPerUserPrices
+    )
+
     webRouter.post(
       '/user/subscription/group/upgrade-subscription',
       AuthenticationController.requireLogin(),

+ 24 - 16
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

@@ -34,6 +34,20 @@ function serializeMongooseObject(object) {
     : object
 }
 
+async function isEligibleForGroupPlan(service, isInTrial) {
+  if (isInTrial) {
+    return false
+  }
+
+  if (service === 'recurly') {
+    return true
+  }
+  const [result] = await Modules.promises.hooks.fire(
+    'canUpgradeFromIndividualToGroup'
+  )
+  return result
+}
+
 async function buildUsersSubscriptionViewModel(user, locale = 'en') {
   let {
     personalSubscription,
@@ -112,9 +126,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
         _id: group._id,
         planCode: group.planCode,
         teamName: group.teamName,
-        admin_id: {
-          email: group.admin_id.email,
-        },
+        admin_id: { email: group.admin_id.email },
         userIsGroupManager,
       }
 
@@ -141,10 +153,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
         planCode: group.planCode,
         groupPlan: group.groupPlan,
         teamName: group.teamName,
-        admin_id: {
-          _id: group.admin_id._id,
-          email: group.admin_id.email,
-        },
+        admin_id: { _id: group.admin_id._id, email: group.admin_id.email },
         features: group.features,
         userIsGroupMember,
       }
@@ -221,6 +230,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
 
   if (personalSubscription && paymentRecord && paymentRecord.subscription) {
     // don't return subscription payment information
+    personalSubscription.service =
+      personalSubscription.paymentProvider?.service ?? 'recurly'
     delete personalSubscription.paymentProvider
     delete personalSubscription.recurly
     delete personalSubscription.recurlySubscription_id
@@ -277,8 +288,10 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
         !isInTrial &&
         !paymentRecord.subscription.planCode.includes('ann') &&
         !paymentRecord.subscription.addOns?.length > 0,
-      isEligibleForGroupPlan:
-        paymentRecord.subscription.service === 'recurly' && !isInTrial,
+      isEligibleForGroupPlan: await isEligibleForGroupPlan(
+        paymentRecord.subscription.service,
+        isInTrial
+      ),
     }
 
     const isMonthlyCollaboratorPlan =
@@ -405,9 +418,7 @@ async function getUsersSubscriptionDetails(user) {
     individualSubscription =
       await SubscriptionLocator.promises.getUsersSubscription(user)
   }
-  let bestSubscription = {
-    type: 'free',
-  }
+  let bestSubscription = { type: 'free' }
   if (currentInstitutionsWithLicence?.length) {
     for (const institutionMembership of currentInstitutionsWithLicence) {
       const plan = PlansLocator.findLocalPlanInSettings(
@@ -601,8 +612,5 @@ module.exports = {
   buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel),
   buildPlansList,
   buildPlansListForSubscriptionDash,
-  promises: {
-    buildUsersSubscriptionViewModel,
-    getUsersSubscriptionDetails,
-  },
+  promises: { buildUsersSubscriptionViewModel, getUsersSubscriptionDetails },
 }

+ 38 - 27
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -22,7 +22,8 @@ import { Institution } from '../../../../../types/institution'
 import getMeta from '../../../utils/meta'
 import {
   loadDisplayPriceWithTaxPromise,
-  loadGroupDisplayPriceWithTaxPromise,
+  loadGroupDisplayPriceWithTaxForRecurlyPromise,
+  loadGroupDisplayPriceWithTaxForStripePromise,
 } from '../util/recurly-pricing'
 import { isRecurlyLoaded } from '../util/is-recurly-loaded'
 import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids'
@@ -199,36 +200,46 @@ export function SubscriptionDashboardProvider({
 
   useEffect(() => {
     if (
-      isRecurlyLoaded() &&
-      groupPlanToChangeToCode &&
-      groupPlanToChangeToSize &&
-      groupPlanToChangeToUsage &&
-      personalSubscription?.payment
+      !groupPlanToChangeToCode ||
+      !groupPlanToChangeToSize ||
+      !groupPlanToChangeToUsage ||
+      !personalSubscription?.payment
     ) {
-      setQueryingGroupPlanToChangeToPrice(true)
+      return
+    }
 
-      const { currency, taxRate } = personalSubscription.payment
-      const fetchGroupDisplayPrice = async () => {
-        setGroupPlanToChangeToPriceError(false)
-        let priceData
-        try {
-          priceData = await loadGroupDisplayPriceWithTaxPromise(
-            groupPlanToChangeToCode,
-            currency,
-            taxRate,
-            groupPlanToChangeToSize,
-            groupPlanToChangeToUsage,
-            i18n.language
-          )
-        } catch (e) {
-          debugConsole.error(e)
-          setGroupPlanToChangeToPriceError(true)
-        }
-        setQueryingGroupPlanToChangeToPrice(false)
-        setGroupPlanToChangeToPrice(priceData)
+    let loadGroupDisplayPrice
+    if (personalSubscription.service?.includes('stripe')) {
+      loadGroupDisplayPrice = loadGroupDisplayPriceWithTaxForStripePromise
+    } else if (isRecurlyLoaded()) {
+      loadGroupDisplayPrice = loadGroupDisplayPriceWithTaxForRecurlyPromise
+    } else {
+      return
+    }
+
+    setQueryingGroupPlanToChangeToPrice(true)
+
+    const { currency, taxRate } = personalSubscription.payment
+    const fetchGroupDisplayPrice = async () => {
+      setGroupPlanToChangeToPriceError(false)
+      let priceData
+      try {
+        priceData = await loadGroupDisplayPrice(
+          groupPlanToChangeToCode,
+          currency,
+          taxRate,
+          groupPlanToChangeToSize,
+          groupPlanToChangeToUsage,
+          i18n.language
+        )
+      } catch (e) {
+        debugConsole.error(e)
+        setGroupPlanToChangeToPriceError(true)
       }
-      fetchGroupDisplayPrice()
+      setQueryingGroupPlanToChangeToPrice(false)
+      setGroupPlanToChangeToPrice(priceData)
     }
+    fetchGroupDisplayPrice()
   }, [
     groupPlanToChangeToUsage,
     groupPlanToChangeToSize,

+ 8 - 0
services/web/frontend/js/features/subscription/util/recurly-group-plan-code.ts

@@ -5,3 +5,11 @@ export function getRecurlyGroupPlanCode(
 ) {
   return `group_${planCode}_${size}_${usage}`
 }
+
+export function getConsolidatedGroupPlanCode(planCode: string, usage: string) {
+  if (usage === 'enterprise') {
+    return `group_${planCode}`
+  }
+
+  return `group_${planCode}_${usage}`
+}

+ 49 - 2
services/web/frontend/js/features/subscription/util/recurly-pricing.ts

@@ -1,9 +1,15 @@
 import { SubscriptionPricingState } from '@recurly/recurly-js'
 import { PriceForDisplayData } from '../../../../../types/subscription/plan'
 import { CurrencyCode } from '../../../../../types/subscription/currency'
-import { getRecurlyGroupPlanCode } from './recurly-group-plan-code'
+import {
+  getRecurlyGroupPlanCode,
+  getConsolidatedGroupPlanCode,
+} from './recurly-group-plan-code'
 import { debugConsole } from '@/utils/debugging'
 import { formatCurrency } from '@/shared/utils/currency'
+import { getJSON } from '../../../infrastructure/fetch-json'
+
+let groupPlanPerUserPrices: Record<string, number> | undefined
 
 function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) {
   return new Promise(resolve => {
@@ -73,7 +79,7 @@ export async function loadDisplayPriceWithTaxPromise(
     )
 }
 
-export async function loadGroupDisplayPriceWithTaxPromise(
+export async function loadGroupDisplayPriceWithTaxForRecurlyPromise(
   groupPlanCode: string,
   currencyCode: CurrencyCode,
   taxRate: number,
@@ -102,3 +108,44 @@ export async function loadGroupDisplayPriceWithTaxPromise(
 
   return price
 }
+
+export async function loadGroupDisplayPriceWithTaxForStripePromise(
+  groupPlanCode: string,
+  currencyCode: CurrencyCode,
+  taxRate: number,
+  size: string,
+  usage: string,
+  locale: string
+) {
+  if (!groupPlanPerUserPrices) {
+    groupPlanPerUserPrices = await getJSON<Record<string, number>>(
+      `/user/subscription/group/group-plan-per-user-prices?currency=${currencyCode}`
+    )
+  }
+
+  const planCode = getConsolidatedGroupPlanCode(groupPlanCode, usage)
+
+  if (!(planCode in groupPlanPerUserPrices)) {
+    throw new Error(
+      `Group plan code ${planCode} not found in groupPlanPerUserPrices`
+    )
+  }
+
+  const subtotalPrice = groupPlanPerUserPrices[planCode] * parseInt(size)
+
+  const result = formatPriceForDisplayData(
+    subtotalPrice.toString(),
+    taxRate,
+    currencyCode,
+    locale
+  )
+
+  result.perUserDisplayPrice = formatCurrency(
+    groupPlanPerUserPrices[planCode],
+    currencyCode,
+    locale,
+    true
+  )
+
+  return result
+}

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

@@ -78,7 +78,6 @@ export interface Meta {
   // dynamic keys based on permissions
   'ol-canUseAddSeatsFeature': boolean
   'ol-canUseFlexibleLicensing': boolean
-  'ol-canUseFlexibleLicensingForConsolidatedPlans': boolean
   'ol-cannot-add-secondary-email': boolean
   'ol-cannot-change-password': boolean
   'ol-cannot-delete-own-account': boolean

+ 91 - 12
services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js

@@ -27,6 +27,10 @@ describe('PaymentProviderEntities', function () {
           { planCode: 'cheap-plan', price_in_cents: 500 },
           { planCode: 'regular-plan', price_in_cents: 1000 },
           { planCode: 'premium-plan', price_in_cents: 2000 },
+          {
+            planCode: 'group_collaborator_10_enterprise',
+            price_in_cents: 10000,
+          },
         ],
         features: [],
       }
@@ -81,8 +85,11 @@ describe('PaymentProviderEntities', function () {
         it('returns a change request for upgrades', function () {
           const { PaymentProviderSubscriptionChangeRequest } =
             this.PaymentProviderEntities
-          const changeRequest =
-            this.subscription.getRequestForPlanChange('premium-plan')
+          const changeRequest = this.subscription.getRequestForPlanChange(
+            'premium-plan',
+            1,
+            this.subscription.shouldPlanChangeAtTermEnd('premium-plan')
+          )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
               subscription: this.subscription,
@@ -95,8 +102,11 @@ describe('PaymentProviderEntities', function () {
         it('returns a change request for downgrades', function () {
           const { PaymentProviderSubscriptionChangeRequest } =
             this.PaymentProviderEntities
-          const changeRequest =
-            this.subscription.getRequestForPlanChange('cheap-plan')
+          const changeRequest = this.subscription.getRequestForPlanChange(
+            'cheap-plan',
+            1,
+            this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
+          )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
               subscription: this.subscription,
@@ -112,8 +122,11 @@ describe('PaymentProviderEntities', function () {
           this.subscription.trialPeriodEnd = fiveDaysFromNow
           const { PaymentProviderSubscriptionChangeRequest } =
             this.PaymentProviderEntities
-          const changeRequest =
-            this.subscription.getRequestForPlanChange('cheap-plan')
+          const changeRequest = this.subscription.getRequestForPlanChange(
+            'cheap-plan',
+            1,
+            this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
+          )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
               subscription: this.subscription,
@@ -127,8 +140,11 @@ describe('PaymentProviderEntities', function () {
           const { PaymentProviderSubscriptionChangeRequest } =
             this.PaymentProviderEntities
           this.addOn.code = AI_ADD_ON_CODE
-          const changeRequest =
-            this.subscription.getRequestForPlanChange('premium-plan')
+          const changeRequest = this.subscription.getRequestForPlanChange(
+            'premium-plan',
+            1,
+            this.subscription.shouldPlanChangeAtTermEnd('premium-plan')
+          )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
               subscription: this.subscription,
@@ -148,8 +164,11 @@ describe('PaymentProviderEntities', function () {
           const { PaymentProviderSubscriptionChangeRequest } =
             this.PaymentProviderEntities
           this.addOn.code = AI_ADD_ON_CODE
-          const changeRequest =
-            this.subscription.getRequestForPlanChange('cheap-plan')
+          const changeRequest = this.subscription.getRequestForPlanChange(
+            'cheap-plan',
+            1,
+            this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
+          )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
               subscription: this.subscription,
@@ -170,8 +189,11 @@ describe('PaymentProviderEntities', function () {
             this.PaymentProviderEntities
           this.subscription.planCode = 'assistant-annual'
           this.subscription.addOns = []
-          const changeRequest =
-            this.subscription.getRequestForPlanChange('cheap-plan')
+          const changeRequest = this.subscription.getRequestForPlanChange(
+            'cheap-plan',
+            1,
+            this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
+          )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
               subscription: this.subscription,
@@ -186,6 +208,63 @@ describe('PaymentProviderEntities', function () {
             })
           )
         })
+
+        it('upgrade from individual to group plan for Stripe subscription', function () {
+          this.subscription.service = 'stripe-uk'
+          const { PaymentProviderSubscriptionChangeRequest } =
+            this.PaymentProviderEntities
+          const changeRequest = this.subscription.getRequestForPlanChange(
+            'group_collaborator',
+            10,
+            this.subscription.shouldPlanChangeAtTermEnd(
+              'group_collaborator_10_enterprise'
+            )
+          )
+          expect(changeRequest).to.deep.equal(
+            new PaymentProviderSubscriptionChangeRequest({
+              subscription: this.subscription,
+              timeframe: 'now',
+              planCode: 'group_collaborator',
+              addOnUpdates: [
+                new PaymentProviderSubscriptionAddOnUpdate({
+                  code: 'additional-license',
+                  quantity: 10,
+                }),
+              ],
+            })
+          )
+        })
+
+        it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function () {
+          this.subscription.service = 'stripe-uk'
+          const { PaymentProviderSubscriptionChangeRequest } =
+            this.PaymentProviderEntities
+          this.addOn.code = AI_ADD_ON_CODE
+          const changeRequest = this.subscription.getRequestForPlanChange(
+            'group_collaborator',
+            10,
+            this.subscription.shouldPlanChangeAtTermEnd(
+              'group_collaborator_10_enterprise'
+            )
+          )
+          expect(changeRequest).to.deep.equal(
+            new PaymentProviderSubscriptionChangeRequest({
+              subscription: this.subscription,
+              timeframe: 'now',
+              planCode: 'group_collaborator',
+              addOnUpdates: [
+                new PaymentProviderSubscriptionAddOnUpdate({
+                  code: 'additional-license',
+                  quantity: 10,
+                }),
+                new PaymentProviderSubscriptionAddOnUpdate({
+                  code: AI_ADD_ON_CODE,
+                  quantity: 1,
+                }),
+              ],
+            })
+          )
+        })
       })
 
       describe('getRequestForAddOnPurchase()', function () {

+ 35 - 0
services/web/test/unit/src/Subscription/PlansLocatorTests.js

@@ -266,4 +266,39 @@ describe('PlansLocator', function () {
       expect(period).to.equal('annual')
     })
   })
+
+  describe('convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded', function () {
+    it('returns original plan name for non-group plan codes', function () {
+      expect(
+        this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
+          'professional'
+        )
+      ).to.deep.equal({
+        planCode: 'professional',
+        quantity: 1,
+      })
+    })
+
+    it('converts Recurly enterprise group plan codes to Stripe group plan codes', function () {
+      expect(
+        this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
+          'group_collaborator_10_enterprise'
+        )
+      ).to.deep.equal({
+        planCode: 'group_collaborator',
+        quantity: 10,
+      })
+    })
+
+    it('converts Recurly educational group plan codes to Stripe group plan codes', function () {
+      expect(
+        this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
+          'group_professional_10_educational'
+        )
+      ).to.deep.equal({
+        planCode: 'group_professional_educational',
+        quantity: 10,
+      })
+    })
+  })
 })

+ 9 - 0
services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js

@@ -150,6 +150,11 @@ describe('SubscriptionViewModelBuilder', function () {
     this.PlansLocator = {
       findLocalPlanInSettings: sinon.stub(),
     }
+    this.SplitTestHandler = {
+      promises: {
+        getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
+      },
+    }
     this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, {
       requires: {
         '@overleaf/settings': this.Settings,
@@ -168,6 +173,7 @@ describe('SubscriptionViewModelBuilder', function () {
         './V1SubscriptionManager': {},
         '../Publishers/PublishersGetter': this.PublishersGetter,
         './SubscriptionHelper': SubscriptionHelper,
+        '../SplitTests/SplitTestHandler': this.SplitTestHandler,
       },
     })
 
@@ -659,6 +665,9 @@ describe('SubscriptionViewModelBuilder', function () {
       describe('isEligibleForGroupPlan', function () {
         it('is false for Stripe subscriptions', async function () {
           this.paymentRecord.service = 'stripe-us'
+          this.Modules.promises.hooks.fire
+            .withArgs('canUpgradeFromIndividualToGroup')
+            .resolves([false])
           const result =
             await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
               this.user