Forráskód Böngészése

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

[web] Unlock self-served license purchasing for legacy plans

GitOrigin-RevId: bf3083d00a77417f0e78d2145f6192c57b163273
Liangjun Song 1 éve
szülő
commit
6245e81f42

+ 14 - 3
services/web/app/src/Features/Subscription/GroupPlansData.js

@@ -42,7 +42,7 @@ for (const [usage, planData] of Object.entries(groups)) {
 
 
     // Generate plans in settings
     // Generate plans in settings
     for (const size of sizes) {
     for (const size of sizes) {
-      Settings.plans.push({
+      let plan = {
         planCode: `group_${planCode}_${size}_${usage}`,
         planCode: `group_${planCode}_${size}_${usage}`,
         name: `${
         name: `${
           Settings.appName
           Settings.appName
@@ -55,8 +55,19 @@ for (const [usage, planData] of Object.entries(groups)) {
         features: Settings.features[planCode],
         features: Settings.features[planCode],
         groupPlan: true,
         groupPlan: true,
         membersLimit: parseInt(size),
         membersLimit: parseInt(size),
-        membersLimitAddOn: 'additional-license',
-      })
+        // Unlock flexible licensing for all plans
+        canUseFlexibleLicensing: true,
+      }
+
+      // Add the `membersLimitAddOn` only to group plans of 5 or greater size
+      if (size >= 5) {
+        plan = {
+          ...plan,
+          membersLimitAddOn: 'additional-license',
+        }
+      }
+
+      Settings.plans.push(plan)
     }
     }
   }
   }
 }
 }

+ 3 - 1
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -247,7 +247,8 @@ function subscriptionFromApi(apiSubscription) {
     apiSubscription.total == null ||
     apiSubscription.total == null ||
     apiSubscription.currency == null ||
     apiSubscription.currency == null ||
     apiSubscription.currentPeriodStartedAt == null ||
     apiSubscription.currentPeriodStartedAt == null ||
-    apiSubscription.currentPeriodEndsAt == null
+    apiSubscription.currentPeriodEndsAt == null ||
+    apiSubscription.createdAt == null
   ) {
   ) {
     throw new OError('Invalid Recurly subscription', {
     throw new OError('Invalid Recurly subscription', {
       subscription: apiSubscription,
       subscription: apiSubscription,
@@ -268,6 +269,7 @@ function subscriptionFromApi(apiSubscription) {
     currency: apiSubscription.currency,
     currency: apiSubscription.currency,
     periodStart: apiSubscription.currentPeriodStartedAt,
     periodStart: apiSubscription.currentPeriodStartedAt,
     periodEnd: apiSubscription.currentPeriodEndsAt,
     periodEnd: apiSubscription.currentPeriodEndsAt,
+    createdAt: apiSubscription.createdAt,
   })
   })
 
 
   if (apiSubscription.pendingChange != null) {
   if (apiSubscription.pendingChange != null) {

+ 9 - 2
services/web/app/src/Features/Subscription/RecurlyEntities.js

@@ -6,6 +6,7 @@ const PlansLocator = require('./PlansLocator')
 const SubscriptionHelper = require('./SubscriptionHelper')
 const SubscriptionHelper = require('./SubscriptionHelper')
 
 
 const AI_ADD_ON_CODE = 'assistant'
 const AI_ADD_ON_CODE = 'assistant'
+const MEMBERS_LIMIT_ADD_ON_CODE = 'additional-license'
 const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
 const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
 
 
 class RecurlySubscription {
 class RecurlySubscription {
@@ -24,6 +25,7 @@ class RecurlySubscription {
    * @param {number} props.total
    * @param {number} props.total
    * @param {Date} props.periodStart
    * @param {Date} props.periodStart
    * @param {Date} props.periodEnd
    * @param {Date} props.periodEnd
+   * @param {Date} props.createdAt
    * @param {RecurlySubscriptionChange} [props.pendingChange]
    * @param {RecurlySubscriptionChange} [props.pendingChange]
    */
    */
   constructor(props) {
   constructor(props) {
@@ -40,6 +42,7 @@ class RecurlySubscription {
     this.total = props.total
     this.total = props.total
     this.periodStart = props.periodStart
     this.periodStart = props.periodStart
     this.periodEnd = props.periodEnd
     this.periodEnd = props.periodEnd
+    this.createdAt = props.createdAt
     this.pendingChange = props.pendingChange ?? null
     this.pendingChange = props.pendingChange ?? null
   }
   }
 
 
@@ -129,12 +132,13 @@ class RecurlySubscription {
    *
    *
    * @param {string} code
    * @param {string} code
    * @param {number} [quantity]
    * @param {number} [quantity]
+   * @param {number} [unitPrice]
    * @return {RecurlySubscriptionChangeRequest} - the change request to send to
    * @return {RecurlySubscriptionChangeRequest} - the change request to send to
    * Recurly
    * Recurly
    *
    *
    * @throws {DuplicateAddOnError} if the add-on is already present on the subscription
    * @throws {DuplicateAddOnError} if the add-on is already present on the subscription
    */
    */
-  getRequestForAddOnPurchase(code, quantity = 1) {
+  getRequestForAddOnPurchase(code, quantity = 1, unitPrice) {
     if (this.hasAddOn(code)) {
     if (this.hasAddOn(code)) {
       throw new DuplicateAddOnError('Subscription already has add-on', {
       throw new DuplicateAddOnError('Subscription already has add-on', {
         subscriptionId: this.id,
         subscriptionId: this.id,
@@ -143,7 +147,9 @@ class RecurlySubscription {
     }
     }
 
 
     const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
     const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
-    addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
+    addOnUpdates.push(
+      new RecurlySubscriptionAddOnUpdate({ code, quantity, unitPrice })
+    )
     return new RecurlySubscriptionChangeRequest({
     return new RecurlySubscriptionChangeRequest({
       subscription: this,
       subscription: this,
       timeframe: 'now',
       timeframe: 'now',
@@ -431,6 +437,7 @@ function isStandaloneAiAddOnPlanCode(planCode) {
 
 
 module.exports = {
 module.exports = {
   AI_ADD_ON_CODE,
   AI_ADD_ON_CODE,
+  MEMBERS_LIMIT_ADD_ON_CODE,
   RecurlySubscription,
   RecurlySubscription,
   RecurlySubscriptionAddOn,
   RecurlySubscriptionAddOn,
   RecurlySubscriptionChange,
   RecurlySubscriptionChange,

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

@@ -128,6 +128,7 @@ async function addSeatsToGroupSubscription(req, res) {
         req
         req
       )
       )
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
+    await SubscriptionGroupHandler.promises.ensureAddSeatsEnabled(plan)
 
 
     res.render('subscriptions/add-seats', {
     res.render('subscriptions/add-seats', {
       subscriptionId: subscription._id,
       subscriptionId: subscription._id,

+ 51 - 10
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js

@@ -7,6 +7,8 @@ const SessionManager = require('../Authentication/SessionManager')
 const RecurlyClient = require('./RecurlyClient')
 const RecurlyClient = require('./RecurlyClient')
 const PlansLocator = require('./PlansLocator')
 const PlansLocator = require('./PlansLocator')
 const SubscriptionHandler = require('./SubscriptionHandler')
 const SubscriptionHandler = require('./SubscriptionHandler')
+const GroupPlansData = require('./GroupPlansData')
+const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./RecurlyEntities')
 
 
 async function removeUserFromGroup(subscriptionId, userIdToRemove) {
 async function removeUserFromGroup(subscriptionId, userIdToRemove) {
   await SubscriptionUpdater.promises.removeUserFromGroup(
   await SubscriptionUpdater.promises.removeUserFromGroup(
@@ -57,7 +59,13 @@ async function _replaceInArray(model, property, oldValue, newValue) {
 
 
 async function ensureFlexibleLicensingEnabled(plan) {
 async function ensureFlexibleLicensingEnabled(plan) {
   if (!plan?.canUseFlexibleLicensing) {
   if (!plan?.canUseFlexibleLicensing) {
-    throw new Error('The group plan does not support flexible licencing')
+    throw new Error('The group plan does not support flexible licensing')
+  }
+}
+
+async function ensureAddSeatsEnabled(plan) {
+  if (!plan?.membersLimitAddOn) {
+    throw new Error('The group plan does not support adding seats')
   }
   }
 }
 }
 
 
@@ -88,29 +96,60 @@ async function _addSeatsSubscriptionChange(req) {
   const { recurlySubscription, plan } =
   const { recurlySubscription, plan } =
     await getUsersGroupSubscriptionDetails(req)
     await getUsersGroupSubscriptionDetails(req)
   await ensureFlexibleLicensingEnabled(plan)
   await ensureFlexibleLicensingEnabled(plan)
+  await ensureAddSeatsEnabled(plan)
   const userId = SessionManager.getLoggedInUserId(req.session)
   const userId = SessionManager.getLoggedInUserId(req.session)
   const currentAddonQuantity =
   const currentAddonQuantity =
     recurlySubscription.addOns.find(
     recurlySubscription.addOns.find(
-      addOn => addOn.code === plan.membersLimitAddOn
+      addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE
     )?.quantity ?? 0
     )?.quantity ?? 0
   // Keeps only the new total quantity of addon
   // Keeps only the new total quantity of addon
   const nextAddonQuantity = currentAddonQuantity + adding
   const nextAddonQuantity = currentAddonQuantity + adding
-  const changeRequest = recurlySubscription.getRequestForAddOnUpdate(
-    plan.membersLimitAddOn,
-    nextAddonQuantity
-  )
+
+  let changeRequest
+  if (recurlySubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) {
+    // Not providing a custom price as once the subscription is locked
+    // to an add-on at a given price, it will use it for subsequent payments
+    changeRequest = recurlySubscription.getRequestForAddOnUpdate(
+      MEMBERS_LIMIT_ADD_ON_CODE,
+      nextAddonQuantity
+    )
+  } else {
+    let unitPrice
+    const newPlanPricesAppliedAt = new Date('2025-01-08T14:00:00Z')
+    const isLegacyPriceApplicable =
+      new Date(recurlySubscription.createdAt) < newPlanPricesAppliedAt
+
+    if (isLegacyPriceApplicable) {
+      const pattern =
+        /^group_(collaborator|professional)_(5|10|20|50)_(educational|enterprise)$/
+      const [, planCode, size, usage] = plan.planCode.match(pattern)
+      const currency = recurlySubscription.currency
+      const legacyPriceInCents =
+        GroupPlansData[usage][planCode][currency][size]
+          .additional_license_legacy_price_in_cents
+
+      if (legacyPriceInCents > 0) {
+        unitPrice = legacyPriceInCents / 100
+      }
+    }
+
+    changeRequest = recurlySubscription.getRequestForAddOnPurchase(
+      MEMBERS_LIMIT_ADD_ON_CODE,
+      nextAddonQuantity,
+      unitPrice
+    )
+  }
 
 
   return {
   return {
     changeRequest,
     changeRequest,
     userId,
     userId,
     currentAddonQuantity,
     currentAddonQuantity,
     recurlySubscription,
     recurlySubscription,
-    plan,
   }
   }
 }
 }
 
 
 async function previewAddSeatsSubscriptionChange(req) {
 async function previewAddSeatsSubscriptionChange(req) {
-  const { changeRequest, userId, currentAddonQuantity, plan } =
+  const { changeRequest, userId, currentAddonQuantity } =
     await _addSeatsSubscriptionChange(req)
     await _addSeatsSubscriptionChange(req)
   const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
   const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
   const subscriptionChange =
   const subscriptionChange =
@@ -120,9 +159,9 @@ async function previewAddSeatsSubscriptionChange(req) {
       {
       {
         type: 'add-on-update',
         type: 'add-on-update',
         addOn: {
         addOn: {
-          code: plan.membersLimitAddOn,
+          code: MEMBERS_LIMIT_ADD_ON_CODE,
           quantity: subscriptionChange.nextAddOns.find(
           quantity: subscriptionChange.nextAddOns.find(
-            addon => addon.code === plan.membersLimitAddOn
+            addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE
           ).quantity,
           ).quantity,
           prevQuantity: currentAddonQuantity,
           prevQuantity: currentAddonQuantity,
         },
         },
@@ -216,6 +255,7 @@ module.exports = {
   removeUserFromGroup: callbackify(removeUserFromGroup),
   removeUserFromGroup: callbackify(removeUserFromGroup),
   replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
   replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
   ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
   ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
+  ensureAddSeatsEnabled: callbackify(ensureAddSeatsEnabled),
   getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
   getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
@@ -224,6 +264,7 @@ module.exports = {
     removeUserFromGroup,
     removeUserFromGroup,
     replaceUserReferencesInGroups,
     replaceUserReferencesInGroups,
     ensureFlexibleLicensingEnabled,
     ensureFlexibleLicensingEnabled,
+    ensureAddSeatsEnabled,
     getTotalConfirmedUsersInGroup,
     getTotalConfirmedUsersInGroup,
     isUserPartOfGroup,
     isUserPartOfGroup,
     getUsersGroupSubscriptionDetails,
     getUsersGroupSubscriptionDetails,

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 328 - 164
services/web/app/templates/plans/groups.json


+ 318 - 0
services/web/scripts/plan-prices/additional-license-add-on-legacy-prices.json

@@ -0,0 +1,318 @@
+{
+  "educational": {
+    "professional": {
+      "5": {
+        "AUD": 321,
+        "BRL": 699,
+        "CAD": 314,
+        "CHF": 279,
+        "CLP": 168693,
+        "COP": 552930,
+        "DKK": 1665,
+        "EUR": 258,
+        "GBP": 223,
+        "INR": 6719,
+        "MXN": 4129,
+        "NOK": 2008,
+        "NZD": 321,
+        "PEN": 671,
+        "SEK": 2008,
+        "SGD": 363,
+        "USD": 279
+      },
+      "10": {
+        "AUD": 179,
+        "BRL": 389,
+        "CAD": 175,
+        "CHF": 155,
+        "CLP": 93986,
+        "COP": 308061,
+        "DKK": 927,
+        "EUR": 143,
+        "GBP": 124,
+        "INR": 3743,
+        "MXN": 2300,
+        "NOK": 1118,
+        "NZD": 179,
+        "PEN": 374,
+        "SEK": 1118,
+        "SGD": 202,
+        "USD": 155
+      },
+      "20": {
+        "AUD": 165,
+        "BRL": 359,
+        "CAD": 161,
+        "CHF": 143,
+        "CLP": 86756,
+        "COP": 284364,
+        "DKK": 856,
+        "EUR": 132,
+        "GBP": 114,
+        "INR": 3455,
+        "MXN": 2123,
+        "NOK": 1032,
+        "NZD": 165,
+        "PEN": 345,
+        "SEK": 1032,
+        "SGD": 186,
+        "USD": 143
+      },
+      "50": {
+        "AUD": 151,
+        "BRL": 329,
+        "CAD": 148,
+        "CHF": 131,
+        "CLP": 79526,
+        "COP": 260667,
+        "DKK": 785,
+        "EUR": 121,
+        "GBP": 105,
+        "INR": 3167,
+        "MXN": 1946,
+        "NOK": 946,
+        "NZD": 151,
+        "PEN": 316,
+        "SEK": 946,
+        "SGD": 171,
+        "USD": 131
+      }
+    },
+    "collaborator": {
+      "5": {
+        "AUD": 167,
+        "BRL": 349,
+        "CAD": 160,
+        "CHF": 139,
+        "CLP": 77693,
+        "COP": 272930,
+        "DKK": 839,
+        "EUR": 125,
+        "GBP": 111,
+        "INR": 3219,
+        "MXN": 2029,
+        "NOK": 1014,
+        "NZD": 167,
+        "PEN": 321,
+        "SEK": 1014,
+        "SGD": 181,
+        "USD": 139
+      },
+      "10": {
+        "AUD": 93,
+        "BRL": 194,
+        "CAD": 89,
+        "CHF": 77,
+        "CLP": 43286,
+        "COP": 152061,
+        "DKK": 467,
+        "EUR": 69,
+        "GBP": 62,
+        "INR": 1793,
+        "MXN": 1130,
+        "NOK": 565,
+        "NZD": 93,
+        "PEN": 179,
+        "SEK": 565,
+        "SGD": 101,
+        "USD": 77
+      },
+      "20": {
+        "AUD": 86,
+        "BRL": 179,
+        "CAD": 82,
+        "CHF": 71,
+        "CLP": 39956,
+        "COP": 140364,
+        "DKK": 431,
+        "EUR": 64,
+        "GBP": 57,
+        "INR": 1655,
+        "MXN": 1043,
+        "NOK": 521,
+        "NZD": 86,
+        "PEN": 165,
+        "SEK": 521,
+        "SGD": 93,
+        "USD": 71
+      },
+      "50": {
+        "AUD": 78,
+        "BRL": 164,
+        "CAD": 75,
+        "CHF": 65,
+        "CLP": 36626,
+        "COP": 128667,
+        "DKK": 395,
+        "EUR": 59,
+        "GBP": 52,
+        "INR": 1517,
+        "MXN": 956,
+        "NOK": 478,
+        "NZD": 78,
+        "PEN": 151,
+        "SEK": 478,
+        "SGD": 85,
+        "USD": 65
+      }
+    }
+  },
+  "enterprise": {
+    "professional": {
+      "5": {
+        "AUD": 321,
+        "BRL": 699,
+        "CAD": 314,
+        "CHF": 499,
+        "CLP": 168693,
+        "COP": 552930,
+        "DKK": 1665,
+        "EUR": 258,
+        "GBP": 223,
+        "INR": 6719,
+        "MXN": 4129,
+        "NOK": 2008,
+        "NZD": 321,
+        "PEN": 671,
+        "SEK": 2008,
+        "SGD": 363,
+        "USD": 279
+      },
+      "10": {
+        "AUD": 298,
+        "BRL": 649,
+        "CAD": 291,
+        "CHF": 259,
+        "CLP": 156643,
+        "COP": 513435,
+        "DKK": 1546,
+        "EUR": 239,
+        "GBP": 207,
+        "INR": 6239,
+        "MXN": 3834,
+        "NOK": 1864,
+        "NZD": 298,
+        "PEN": 623,
+        "SEK": 1864,
+        "SGD": 337,
+        "USD": 259
+      },
+      "20": {
+        "AUD": 275,
+        "BRL": 599,
+        "CAD": 269,
+        "CHF": 239,
+        "CLP": 144594,
+        "COP": 473940,
+        "DKK": 1427,
+        "EUR": 221,
+        "GBP": 191,
+        "INR": 5759,
+        "MXN": 3539,
+        "NOK": 1721,
+        "NZD": 275,
+        "PEN": 575,
+        "SEK": 1721,
+        "SGD": 311,
+        "USD": 239
+      },
+      "50": {
+        "AUD": 252,
+        "BRL": 549,
+        "CAD": 246,
+        "CHF": 219,
+        "CLP": 132544,
+        "COP": 400000,
+        "DKK": 1308,
+        "EUR": 202,
+        "GBP": 175,
+        "INR": 5279,
+        "MXN": 3244,
+        "NOK": 1577,
+        "NZD": 252,
+        "PEN": 527,
+        "SEK": 1577,
+        "SGD": 285,
+        "USD": 219
+      }
+    },
+    "collaborator": {
+      "5": {
+        "AUD": 167,
+        "BRL": 349,
+        "CAD": 160,
+        "CHF": 139,
+        "CLP": 77693,
+        "COP": 272930,
+        "DKK": 839,
+        "EUR": 125,
+        "GBP": 111,
+        "INR": 3219,
+        "MXN": 2029,
+        "NOK": 1014,
+        "NZD": 167,
+        "PEN": 321,
+        "SEK": 1014,
+        "SGD": 181,
+        "USD": 139
+      },
+      "10": {
+        "AUD": 155,
+        "BRL": 324,
+        "CAD": 148,
+        "CHF": 129,
+        "CLP": 72143,
+        "COP": 253435,
+        "DKK": 779,
+        "EUR": 116,
+        "GBP": 103,
+        "INR": 2989,
+        "MXN": 1884,
+        "NOK": 941,
+        "NZD": 155,
+        "PEN": 298,
+        "SEK": 941,
+        "SGD": 168,
+        "USD": 129
+      },
+      "20": {
+        "AUD": 143,
+        "BRL": 299,
+        "CAD": 137,
+        "CHF": 119,
+        "CLP": 66594,
+        "COP": 233940,
+        "DKK": 719,
+        "EUR": 107,
+        "GBP": 95,
+        "INR": 2759,
+        "MXN": 1739,
+        "NOK": 869,
+        "NZD": 143,
+        "PEN": 275,
+        "SEK": 869,
+        "SGD": 155,
+        "USD": 119
+      },
+      "50": {
+        "AUD": 131,
+        "BRL": 274,
+        "CAD": 125,
+        "CHF": 109,
+        "CLP": 61044,
+        "COP": 214445,
+        "DKK": 659,
+        "EUR": 98,
+        "GBP": 87,
+        "INR": 2529,
+        "MXN": 1594,
+        "NOK": 796,
+        "NZD": 131,
+        "PEN": 252,
+        "SEK": 796,
+        "SGD": 142,
+        "USD": 109
+      }
+    }
+  }
+}

+ 19 - 0
services/web/scripts/plan-prices/plans.mjs

@@ -135,6 +135,16 @@ function generateGroupPlans(workSheetJSON) {
   )
   )
 
 
   const sizes = ['2', '3', '4', '5', '10', '20', '50']
   const sizes = ['2', '3', '4', '5', '10', '20', '50']
+  const additionalLicenseAddOnLegacyPricesFilePath = path.resolve(
+    __dirname,
+    'additional-license-add-on-legacy-prices.json'
+  )
+  const additionalLicenseAddOnLegacyPricesFile = fs.readFileSync(
+    additionalLicenseAddOnLegacyPricesFilePath
+  )
+  const additionalLicenseAddOnLegacyPrices = JSON.parse(
+    additionalLicenseAddOnLegacyPricesFile
+  )
 
 
   const result = {}
   const result = {}
   for (const type1 of ['educational', 'enterprise']) {
   for (const type1 of ['educational', 'enterprise']) {
@@ -152,6 +162,15 @@ function generateGroupPlans(workSheetJSON) {
           result[type1][type2][currency][size] = {
           result[type1][type2][currency][size] = {
             price_in_cents: plan[currency] * 100,
             price_in_cents: plan[currency] * 100,
           }
           }
+
+          const additionalLicenseAddOnLegacyPrice =
+            additionalLicenseAddOnLegacyPrices[type1][type2][size]?.[currency]
+          if (additionalLicenseAddOnLegacyPrice) {
+            Object.assign(result[type1][type2][currency][size], {
+              additional_license_legacy_price_in_cents:
+                additionalLicenseAddOnLegacyPrice * 100,
+            })
+          }
         }
         }
       }
       }
     }
     }

+ 2 - 0
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -50,6 +50,7 @@ describe('RecurlyClient', function () {
       total: 16.5,
       total: 16.5,
       periodStart: new Date(),
       periodStart: new Date(),
       periodEnd: new Date(),
       periodEnd: new Date(),
+      createdAt: new Date(),
     })
     })
 
 
     this.recurlySubscription = {
     this.recurlySubscription = {
@@ -79,6 +80,7 @@ describe('RecurlyClient', function () {
       currency: this.subscription.currency,
       currency: this.subscription.currency,
       currentPeriodStartedAt: this.subscription.periodStart,
       currentPeriodStartedAt: this.subscription.periodStart,
       currentPeriodEndsAt: this.subscription.periodEnd,
       currentPeriodEndsAt: this.subscription.periodEnd,
+      createdAt: this.subscription.createdAt,
     }
     }
 
 
     this.recurlySubscriptionChange = new recurly.SubscriptionChange()
     this.recurlySubscriptionChange = new recurly.SubscriptionChange()

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

@@ -185,6 +185,38 @@ describe('RecurlyEntities', function () {
           )
           )
         })
         })
 
 
+        it('returns a change request with quantity and unit price specified', function () {
+          const {
+            RecurlySubscriptionChangeRequest,
+            RecurlySubscriptionAddOnUpdate,
+          } = this.RecurlyEntities
+          const quantity = 5
+          const unitPrice = 10
+          const changeRequest = this.subscription.getRequestForAddOnPurchase(
+            'another-add-on',
+            quantity,
+            unitPrice
+          )
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscription: this.subscription,
+              timeframe: 'now',
+              addOnUpdates: [
+                new RecurlySubscriptionAddOnUpdate({
+                  code: this.addOn.code,
+                  quantity: this.addOn.quantity,
+                  unitPrice: this.addOn.unitPrice,
+                }),
+                new RecurlySubscriptionAddOnUpdate({
+                  code: 'another-add-on',
+                  quantity,
+                  unitPrice,
+                }),
+              ],
+            })
+          )
+        })
+
         it('throws a DuplicateAddOnError if the subscription already has the add-on', function () {
         it('throws a DuplicateAddOnError if the subscription already has the add-on', function () {
           expect(() =>
           expect(() =>
             this.subscription.getRequestForAddOnPurchase(this.addOn.code)
             this.subscription.getRequestForAddOnPurchase(this.addOn.code)
@@ -346,6 +378,7 @@ describe('RecurlyEntities', function () {
           total: 11.5,
           total: 11.5,
           periodStart: new Date(),
           periodStart: new Date(),
           periodEnd: new Date(),
           periodEnd: new Date(),
+          createdAt: new Date(),
         })
         })
         const change = new RecurlySubscriptionChange({
         const change = new RecurlySubscriptionChange({
           subscription,
           subscription,

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

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

+ 210 - 58
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -15,9 +15,12 @@ describe('SubscriptionGroupHandler', function () {
     this.subscription_id = '31DSd1123D'
     this.subscription_id = '31DSd1123D'
     this.adding = 1
     this.adding = 1
     this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
     this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
+    this.RecurlyEntities = {
+      MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
+    }
     this.localPlanInSettings = {
     this.localPlanInSettings = {
-      membersLimit: 2,
-      membersLimitAddOn: 'additional-license',
+      membersLimit: 5,
+      membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
     }
     }
 
 
     this.subscription = {
     this.subscription = {
@@ -37,12 +40,21 @@ describe('SubscriptionGroupHandler', function () {
       id: 123,
       id: 123,
       addOns: [
       addOns: [
         {
         {
-          code: 'additional-license',
+          code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
           quantity: 1,
           quantity: 1,
         },
         },
       ],
       ],
       getRequestForAddOnUpdate: sinon.stub().returns(this.changeRequest),
       getRequestForAddOnUpdate: sinon.stub().returns(this.changeRequest),
       getRequestForGroupPlanUpgrade: sinon.stub().returns(this.changeRequest),
       getRequestForGroupPlanUpgrade: sinon.stub().returns(this.changeRequest),
+      getRequestForAddOnPurchase: sinon.stub().returns(this.changeRequest),
+      getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
+        .stub()
+        .returns(this.changeRequest),
+      createdAt: '2025-01-01T00:00:00Z',
+      currency: 'USD',
+      hasAddOn(code) {
+        return this.addOns.some(addOn => addOn.code === code)
+      },
     }
     }
 
 
     this.SubscriptionLocator = {
     this.SubscriptionLocator = {
@@ -81,7 +93,7 @@ describe('SubscriptionGroupHandler', function () {
     this.previewSubscriptionChange = {
     this.previewSubscriptionChange = {
       nextAddOns: [
       nextAddOns: [
         {
         {
-          code: 'additional-license',
+          code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
           quantity: this.recurlySubscription.addOns[0].quantity + this.adding,
           quantity: this.recurlySubscription.addOns[0].quantity + this.adding,
         },
         },
       ],
       ],
@@ -115,6 +127,19 @@ describe('SubscriptionGroupHandler', function () {
       },
       },
     }
     }
 
 
+    this.GroupPlansData = {
+      enterprise: {
+        collaborator: {
+          USD: {
+            5: {
+              price_in_cents: 10000,
+              additional_license_legacy_price_in_cents: 5000,
+            },
+          },
+        },
+      },
+    }
+
     this.Handler = SandboxedModule.require(modulePath, {
     this.Handler = SandboxedModule.require(modulePath, {
       requires: {
       requires: {
         './SubscriptionUpdater': this.SubscriptionUpdater,
         './SubscriptionUpdater': this.SubscriptionUpdater,
@@ -126,7 +151,9 @@ describe('SubscriptionGroupHandler', function () {
         },
         },
         './RecurlyClient': this.RecurlyClient,
         './RecurlyClient': this.RecurlyClient,
         './PlansLocator': this.PlansLocator,
         './PlansLocator': this.PlansLocator,
+        './RecurlyEntities': this.RecurlyEntities,
         '../Authentication/SessionManager': this.SessionManager,
         '../Authentication/SessionManager': this.SessionManager,
+        './GroupPlansData': this.GroupPlansData,
       },
       },
     })
     })
   })
   })
@@ -274,8 +301,8 @@ describe('SubscriptionGroupHandler', function () {
       expect(data).to.deep.equal({
       expect(data).to.deep.equal({
         subscription: { groupPlan: true },
         subscription: { groupPlan: true },
         plan: {
         plan: {
-          membersLimit: 2,
-          membersLimitAddOn: 'additional-license',
+          membersLimit: 5,
+          membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
           canUseFlexibleLicensing: true,
           canUseFlexibleLicensing: true,
         },
         },
         recurlySubscription: this.recurlySubscription,
         recurlySubscription: this.recurlySubscription,
@@ -293,60 +320,169 @@ describe('SubscriptionGroupHandler', function () {
       })
       })
     })
     })
 
 
-    afterEach(function () {
-      this.recurlySubscription.getRequestForAddOnUpdate
-        .calledWith(
-          'additional-license',
-          this.recurlySubscription.addOns[0].quantity + this.adding
-        )
-        .should.equal(true)
-    })
+    describe('has "additional-license" add-on', function () {
+      beforeEach(function () {
+        this.recurlySubscription.addOns = [
+          {
+            code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+            quantity: 6,
+          },
+        ]
+        this.prevQuantity = this.recurlySubscription.addOns[0].quantity
+        this.previewSubscriptionChange.nextAddOns = [
+          {
+            code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+            quantity: this.prevQuantity + this.adding,
+          },
+        ]
+      })
 
 
-    describe('previewAddSeatsSubscriptionChange', function () {
-      it('should return the subscription change preview', async function () {
-        const preview =
-          await this.Handler.promises.previewAddSeatsSubscriptionChange(
-            this.req
-          )
+      afterEach(function () {
+        sinon.assert.notCalled(
+          this.recurlySubscription.getRequestForAddOnPurchase
+        )
 
 
-        this.RecurlyClient.promises.getPaymentMethod
-          .calledWith(this.user_id)
-          .should.equal(true)
-        this.RecurlyClient.promises.previewSubscriptionChange
-          .calledWith(this.changeRequest)
-          .should.equal(true)
-        this.SubscriptionController.makeChangePreview
+        this.recurlySubscription.getRequestForAddOnUpdate
           .calledWith(
           .calledWith(
-            {
-              type: 'add-on-update',
-              addOn: {
-                code: 'additional-license',
-                quantity:
-                  this.recurlySubscription.addOns[0].quantity + this.adding,
-                prevQuantity: this.adding,
-              },
-            },
-            this.previewSubscriptionChange,
-            this.paymentMethod
+            this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+            this.recurlySubscription.addOns[0].quantity + this.adding
           )
           )
           .should.equal(true)
           .should.equal(true)
-        preview.should.equal(this.changePreview)
+      })
+
+      describe('previewAddSeatsSubscriptionChange', function () {
+        it('should return the subscription change preview', async function () {
+          const preview =
+            await this.Handler.promises.previewAddSeatsSubscriptionChange(
+              this.req
+            )
+          this.RecurlyClient.promises.getPaymentMethod
+            .calledWith(this.user_id)
+            .should.equal(true)
+          this.RecurlyClient.promises.previewSubscriptionChange
+            .calledWith(this.changeRequest)
+            .should.equal(true)
+          this.SubscriptionController.makeChangePreview
+            .calledWith(
+              {
+                type: 'add-on-update',
+                addOn: {
+                  code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+                  quantity:
+                    this.previewSubscriptionChange.nextAddOns[0].quantity,
+                  prevQuantity: this.prevQuantity,
+                },
+              },
+              this.previewSubscriptionChange,
+              this.paymentMethod
+            )
+            .should.equal(true)
+          preview.should.equal(this.changePreview)
+        })
+      })
+
+      describe('createAddSeatsSubscriptionChange', function () {
+        it('should change the subscription', async function () {
+          const result =
+            await this.Handler.promises.createAddSeatsSubscriptionChange(
+              this.req
+            )
+
+          this.RecurlyClient.promises.applySubscriptionChangeRequest
+            .calledWith(this.changeRequest)
+            .should.equal(true)
+          this.SubscriptionHandler.promises.syncSubscription
+            .calledWith({ uuid: this.recurlySubscription.id }, this.user_id)
+            .should.equal(true)
+          expect(result).to.deep.equal({
+            adding: this.req.body.adding,
+          })
+        })
       })
       })
     })
     })
 
 
-    describe('createAddSeatsSubscriptionChange', function () {
-      it('should change the subscription', async function () {
-        const result =
-          await this.Handler.promises.createAddSeatsSubscriptionChange(this.req)
+    describe('has no "additional-license" add-on', function () {
+      beforeEach(function () {
+        this.recurlySubscription.addOns = []
+        this.prevQuantity = this.recurlySubscription.addOns[0]?.quantity ?? 0
+        this.previewSubscriptionChange.nextAddOns = [
+          {
+            code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+            quantity: this.prevQuantity + this.adding,
+          },
+        ]
+        this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
+          ...this.localPlanInSettings,
+          planCode: 'group_collaborator_5_enterprise',
+          canUseFlexibleLicensing: true,
+        })
+      })
 
 
-        this.RecurlyClient.promises.applySubscriptionChangeRequest
-          .calledWith(this.changeRequest)
-          .should.equal(true)
-        this.SubscriptionHandler.promises.syncSubscription
-          .calledWith({ uuid: this.recurlySubscription.id }, this.user_id)
-          .should.equal(true)
-        expect(result).to.deep.equal({
-          adding: this.req.body.adding,
+      afterEach(function () {
+        sinon.assert.notCalled(
+          this.recurlySubscription.getRequestForAddOnUpdate
+        )
+      })
+
+      describe('previewAddSeatsSubscriptionChange', function () {
+        let preview
+
+        afterEach(function () {
+          this.RecurlyClient.promises.getPaymentMethod
+            .calledWith(this.user_id)
+            .should.equal(true)
+          this.RecurlyClient.promises.previewSubscriptionChange
+            .calledWith(this.changeRequest)
+            .should.equal(true)
+          this.SubscriptionController.makeChangePreview
+            .calledWith(
+              {
+                type: 'add-on-update',
+                addOn: {
+                  code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+                  quantity:
+                    this.previewSubscriptionChange.nextAddOns[0].quantity,
+                  prevQuantity: this.prevQuantity,
+                },
+              },
+              this.previewSubscriptionChange,
+              this.paymentMethod
+            )
+            .should.equal(true)
+          preview.should.equal(this.changePreview)
+        })
+
+        it('should return the subscription change preview with legacy add-on price', async function () {
+          this.recurlySubscription.createdAt = '2025-01-01T00:00:00Z'
+
+          preview =
+            await this.Handler.promises.previewAddSeatsSubscriptionChange(
+              this.req
+            )
+          this.recurlySubscription.getRequestForAddOnPurchase
+            .calledWithExactly(
+              this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+              this.adding,
+              this.GroupPlansData.enterprise.collaborator.USD[5]
+                .additional_license_legacy_price_in_cents / 100
+            )
+            .should.equal(true)
+        })
+
+        it('should return the subscription change preview with non-legacy add-on price', async function () {
+          this.recurlySubscription.createdAt = '2030-01-01T00:00:00Z'
+
+          preview =
+            await this.Handler.promises.previewAddSeatsSubscriptionChange(
+              this.req
+            )
+          this.recurlySubscription.getRequestForAddOnPurchase
+            .calledWithExactly(
+              this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+              this.adding,
+              undefined
+            )
+            .should.equal(true)
         })
         })
       })
       })
     })
     })
@@ -358,16 +494,32 @@ describe('SubscriptionGroupHandler', function () {
         this.Handler.promises.ensureFlexibleLicensingEnabled({
         this.Handler.promises.ensureFlexibleLicensingEnabled({
           canUseFlexibleLicensing: false,
           canUseFlexibleLicensing: false,
         })
         })
-      ).to.be.rejectedWith('The group plan does not support flexible licencing')
+      ).to.be.rejectedWith('The group plan does not support flexible licensing')
+    })
+
+    it('should not throw if the subscription can use flexible licensing', async function () {
+      await expect(
+        this.Handler.promises.ensureFlexibleLicensingEnabled({
+          canUseFlexibleLicensing: true,
+        })
+      ).to.not.be.rejected
     })
     })
   })
   })
 
 
-  it('should not throw if the subscription can use flexible licensing', async function () {
-    await expect(
-      this.Handler.promises.ensureFlexibleLicensingEnabled({
-        canUseFlexibleLicensing: true,
-      })
-    ).to.not.be.rejected
+  describe('ensureAddSeatsEnabled', function () {
+    it('should throw if the subscription can not use the "add seats" feature', async function () {
+      await expect(
+        this.Handler.promises.ensureAddSeatsEnabled({})
+      ).to.be.rejectedWith('The group plan does not support adding seats')
+    })
+
+    it('should not throw if the subscription can use the "add seats" feature', async function () {
+      await expect(
+        this.Handler.promises.ensureAddSeatsEnabled({
+          membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
+        })
+      ).to.not.be.rejected
+    })
   })
   })
 
 
   describe('upgradeGroupPlan', function () {
   describe('upgradeGroupPlan', function () {

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott