فهرست منبع

Merge pull request #24412 from overleaf/ii-flexible-licensing-manually-collected-2

[web] Add seats feature for manually collected subscriptions

GitOrigin-RevId: f7cc6f8ce17163f10e175a06bb471de6e3a96e3c
ilkin-overleaf 1 سال پیش
والد
کامیت
1a8c549389
19فایلهای تغییر یافته به همراه561 افزوده شده و 51 حذف شده
  1. 52 0
      services/web/app/src/Features/Subscription/PaymentProviderEntities.js
  2. 69 2
      services/web/app/src/Features/Subscription/RecurlyClient.js
  3. 2 2
      services/web/app/src/Features/Subscription/SubscriptionController.js
  4. 41 7
      services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
  5. 43 5
      services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
  6. 2 0
      services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
  7. 1 1
      services/web/app/views/subscriptions/add-seats.pug
  8. 2 0
      services/web/frontend/extracted-translations.json
  9. 16 1
      services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx
  10. 30 0
      services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx
  11. 6 1
      services/web/frontend/js/features/group-management/components/add-seats/root.tsx
  12. 1 0
      services/web/frontend/js/utils/meta.ts
  13. 2 0
      services/web/locales/en.json
  14. 47 5
      services/web/test/frontend/features/group-management/components/add-seats.spec.tsx
  15. 33 0
      services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js
  16. 61 0
      services/web/test/unit/src/Subscription/RecurlyClientTests.js
  17. 29 21
      services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
  18. 123 5
      services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
  19. 1 1
      services/web/types/subscription/subscription-change-preview.ts

+ 52 - 0
services/web/app/src/Features/Subscription/PaymentProviderEntities.js

@@ -30,6 +30,8 @@ class PaymentProviderSubscription {
    * @param {Date} props.periodStart
    * @param {Date} props.periodStart
    * @param {Date} props.periodEnd
    * @param {Date} props.periodEnd
    * @param {string} props.collectionMethod
    * @param {string} props.collectionMethod
+   * @param {string} [props.poNumber]
+   * @param {string} [props.termsAndConditions]
    * @param {PaymentProviderSubscriptionChange} [props.pendingChange]
    * @param {PaymentProviderSubscriptionChange} [props.pendingChange]
    * @param {PaymentProvider['service']} [props.service]
    * @param {PaymentProvider['service']} [props.service]
    * @param {string} [props.state]
    * @param {string} [props.state]
@@ -53,6 +55,8 @@ class PaymentProviderSubscription {
     this.periodStart = props.periodStart
     this.periodStart = props.periodStart
     this.periodEnd = props.periodEnd
     this.periodEnd = props.periodEnd
     this.collectionMethod = props.collectionMethod
     this.collectionMethod = props.collectionMethod
+    this.poNumber = props.poNumber ?? ''
+    this.termsAndConditions = props.termsAndConditions ?? ''
     this.pendingChange = props.pendingChange ?? null
     this.pendingChange = props.pendingChange ?? null
     this.service = props.service ?? 'recurly'
     this.service = props.service ?? 'recurly'
     this.state = props.state ?? 'active'
     this.state = props.state ?? 'active'
@@ -263,6 +267,39 @@ class PaymentProviderSubscription {
     })
     })
   }
   }
 
 
+  /**
+   * Update the "PO number" and "Terms and conditions" in a subscription
+   *
+   * @param {string} poNumber
+   * @param {string} termsAndConditions
+   * @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to
+   * Recurly
+   */
+  getRequestForPoNumberAndTermsAndConditionsUpdate(
+    poNumber,
+    termsAndConditions
+  ) {
+    return new PaymentProviderSubscriptionUpdateRequest({
+      subscription: this,
+      poNumber,
+      termsAndConditions,
+    })
+  }
+
+  /**
+   * Update the "Terms and conditions" in a subscription
+   *
+   * @param {string} termsAndConditions
+   * @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to
+   * Recurly
+   */
+  getRequestForTermsAndConditionsUpdate(termsAndConditions) {
+    return new PaymentProviderSubscriptionUpdateRequest({
+      subscription: this,
+      termsAndConditions,
+    })
+  }
+
   /**
   /**
    * Returns whether this subscription is manually collected
    * Returns whether this subscription is manually collected
    *
    *
@@ -304,6 +341,20 @@ class PaymentProviderSubscriptionAddOn {
   }
   }
 }
 }
 
 
+class PaymentProviderSubscriptionUpdateRequest {
+  /**
+   * @param {object} props
+   * @param {PaymentProviderSubscription} props.subscription
+   * @param {string} [props.poNumber]
+   * @param {string} [props.termsAndConditions]
+   */
+  constructor(props) {
+    this.subscription = props.subscription
+    this.poNumber = props.poNumber ?? ''
+    this.termsAndConditions = props.termsAndConditions ?? ''
+  }
+}
+
 class PaymentProviderSubscriptionChangeRequest {
 class PaymentProviderSubscriptionChangeRequest {
   /**
   /**
    * @param {object} props
    * @param {object} props
@@ -514,6 +565,7 @@ module.exports = {
   PaymentProviderSubscriptionAddOn,
   PaymentProviderSubscriptionAddOn,
   PaymentProviderSubscriptionChange,
   PaymentProviderSubscriptionChange,
   PaymentProviderSubscriptionChangeRequest,
   PaymentProviderSubscriptionChangeRequest,
+  PaymentProviderSubscriptionUpdateRequest,
   PaymentProviderSubscriptionAddOnUpdate,
   PaymentProviderSubscriptionAddOnUpdate,
   PaypalPaymentMethod,
   PaypalPaymentMethod,
   CreditCardPaymentMethod,
   CreditCardPaymentMethod,

+ 69 - 2
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -25,6 +25,7 @@ const {
 
 
 /**
 /**
  * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities'
  * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities'
+ * @import { PaymentProviderSubscriptionUpdateRequest } from './PaymentProviderEntities'
  * @import { PaymentMethod } from './types'
  * @import { PaymentMethod } from './types'
  */
  */
 
 
@@ -188,7 +189,29 @@ async function getSubscriptionForUser(userId) {
 }
 }
 
 
 /**
 /**
- * Request a susbcription change from Recurly
+ * Request a subscription update from Recurly
+ *
+ * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
+ */
+async function updateSubscriptionDetails(updateRequest) {
+  const body = subscriptionUpdateRequestToApi(updateRequest)
+
+  const updatedSubscription = await client.updateSubscription(
+    `uuid-${updateRequest.subscription.id}`,
+    body
+  )
+
+  logger.debug(
+    {
+      subscriptionId: updateRequest.subscription.id,
+      updateId: updatedSubscription.id,
+    },
+    'updated subscription'
+  )
+}
+
+/**
+ * Request a subscription change from Recurly
  *
  *
  * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
  * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
  */
  */
@@ -361,6 +384,25 @@ async function getPlan(planCode) {
   return planFromApi(plan)
   return planFromApi(plan)
 }
 }
 
 
+/**
+ * Get the country code for given user
+ *
+ * @param {string} userId
+ * @return {Promise<string>}
+ */
+async function getCountryCode(userId) {
+  const account = await client.getAccount(`code-${userId}`)
+  const countryCode = account.address?.country
+
+  if (!countryCode) {
+    throw new OError('Country code not found', {
+      userId,
+    })
+  }
+
+  return countryCode
+}
+
 function subscriptionIsCanceledOrExpired(subscription) {
 function subscriptionIsCanceledOrExpired(subscription) {
   const state = subscription?.recurlyStatus?.state
   const state = subscription?.recurlyStatus?.state
   return state === 'canceled' || state === 'expired'
   return state === 'canceled' || state === 'expired'
@@ -424,7 +466,10 @@ function subscriptionFromApi(apiSubscription) {
     apiSubscription.currency == null ||
     apiSubscription.currency == null ||
     apiSubscription.currentPeriodStartedAt == null ||
     apiSubscription.currentPeriodStartedAt == null ||
     apiSubscription.currentPeriodEndsAt == null ||
     apiSubscription.currentPeriodEndsAt == null ||
-    apiSubscription.collectionMethod == null
+    apiSubscription.collectionMethod == null ||
+    // The values below could be null initially if the subscription has never updated
+    !('poNumber' in apiSubscription) ||
+    !('termsAndConditions' in apiSubscription)
   ) {
   ) {
     throw new OError('Invalid Recurly subscription', {
     throw new OError('Invalid Recurly subscription', {
       subscription: apiSubscription,
       subscription: apiSubscription,
@@ -446,6 +491,8 @@ function subscriptionFromApi(apiSubscription) {
     periodStart: apiSubscription.currentPeriodStartedAt,
     periodStart: apiSubscription.currentPeriodStartedAt,
     periodEnd: apiSubscription.currentPeriodEndsAt,
     periodEnd: apiSubscription.currentPeriodEndsAt,
     collectionMethod: apiSubscription.collectionMethod,
     collectionMethod: apiSubscription.collectionMethod,
+    poNumber: apiSubscription.poNumber ?? '',
+    termsAndConditions: apiSubscription.termsAndConditions ?? '',
     service: 'recurly',
     service: 'recurly',
     state: apiSubscription.state ?? 'active',
     state: apiSubscription.state ?? 'active',
     trialPeriodStart: apiSubscription.trialStartedAt,
     trialPeriodStart: apiSubscription.trialStartedAt,
@@ -639,6 +686,22 @@ function subscriptionChangeRequestToApi(changeRequest) {
   return requestBody
   return requestBody
 }
 }
 
 
+/**
+ * Build an API request from a PaymentProviderSubscriptionUpdateRequest
+ *
+ * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
+ */
+function subscriptionUpdateRequestToApi(updateRequest) {
+  const requestBody = {}
+  if (updateRequest.poNumber) {
+    requestBody.poNumber = updateRequest.poNumber
+  }
+  if (updateRequest.termsAndConditions) {
+    requestBody.termsAndConditions = updateRequest.termsAndConditions
+  }
+  return requestBody
+}
+
 module.exports = {
 module.exports = {
   errors: recurly.errors,
   errors: recurly.errors,
 
 
@@ -648,6 +711,7 @@ module.exports = {
   getSubscription: callbackify(getSubscription),
   getSubscription: callbackify(getSubscription),
   getSubscriptionForUser: callbackify(getSubscriptionForUser),
   getSubscriptionForUser: callbackify(getSubscriptionForUser),
   previewSubscriptionChange: callbackify(previewSubscriptionChange),
   previewSubscriptionChange: callbackify(previewSubscriptionChange),
+  updateSubscriptionDetails: callbackify(updateSubscriptionDetails),
   applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
   applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
   removeSubscriptionChange: callbackify(removeSubscriptionChange),
   removeSubscriptionChange: callbackify(removeSubscriptionChange),
   removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
   removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
@@ -656,6 +720,7 @@ module.exports = {
   getPaymentMethod: callbackify(getPaymentMethod),
   getPaymentMethod: callbackify(getPaymentMethod),
   getAddOn: callbackify(getAddOn),
   getAddOn: callbackify(getAddOn),
   getPlan: callbackify(getPlan),
   getPlan: callbackify(getPlan),
+  getCountryCode: callbackify(getCountryCode),
   subscriptionIsCanceledOrExpired,
   subscriptionIsCanceledOrExpired,
   pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
   pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
   resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
   resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
@@ -668,6 +733,7 @@ module.exports = {
     getActiveCouponsForUserId,
     getActiveCouponsForUserId,
     getCustomerManagementLink,
     getCustomerManagementLink,
     previewSubscriptionChange,
     previewSubscriptionChange,
+    updateSubscriptionDetails,
     applySubscriptionChangeRequest,
     applySubscriptionChangeRequest,
     removeSubscriptionChange,
     removeSubscriptionChange,
     removeSubscriptionChangeByUuid,
     removeSubscriptionChangeByUuid,
@@ -678,5 +744,6 @@ module.exports = {
     getPaymentMethod,
     getPaymentMethod,
     getAddOn,
     getAddOn,
     getPlan,
     getPlan,
+    getCountryCode,
   },
   },
 }
 }

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

@@ -723,7 +723,7 @@ function getPlanNameForDisplay(planName, planCode) {
  *
  *
  * @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
  * @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
  * @param {PaymentProviderSubscriptionChange} subscriptionChange The subscription change object coming from Recurly
  * @param {PaymentProviderSubscriptionChange} subscriptionChange The subscription change object coming from Recurly
- * @param {PaymentMethod} paymentMethod The payment method associated to the user
+ * @param {PaymentMethod} [paymentMethod] The payment method associated to the user
  * @return {SubscriptionChangePreview}
  * @return {SubscriptionChangePreview}
  */
  */
 function makeChangePreview(
 function makeChangePreview(
@@ -739,7 +739,7 @@ function makeChangePreview(
     change: subscriptionChangeDescription,
     change: subscriptionChangeDescription,
     currency: subscription.currency,
     currency: subscription.currency,
     immediateCharge: { ...subscriptionChange.immediateCharge },
     immediateCharge: { ...subscriptionChange.immediateCharge },
-    paymentMethod: paymentMethod.toString(),
+    paymentMethod: paymentMethod?.toString(),
     nextPlan: {
     nextPlan: {
       annual: nextPlan.annual ?? false,
       annual: nextPlan.annual ?? false,
     },
     },

+ 41 - 7
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -8,6 +8,7 @@ import SessionManager from '../Authentication/SessionManager.js'
 import UserAuditLogHandler from '../User/UserAuditLogHandler.js'
 import UserAuditLogHandler from '../User/UserAuditLogHandler.js'
 import { expressify } from '@overleaf/promise-utils'
 import { expressify } from '@overleaf/promise-utils'
 import Modules from '../../infrastructure/Modules.js'
 import Modules from '../../infrastructure/Modules.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
 import UserGetter from '../User/UserGetter.js'
 import UserGetter from '../User/UserGetter.js'
 import { Subscription } from '../../models/Subscription.js'
 import { Subscription } from '../../models/Subscription.js'
 import { isProfessionalGroupPlan } from './PlansHelper.mjs'
 import { isProfessionalGroupPlan } from './PlansHelper.mjs'
@@ -135,23 +136,39 @@ async function addSeatsToGroupSubscription(req, res) {
         userId
         userId
       )
       )
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
-    await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
-      recurlySubscription
-    )
     await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
     await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
       recurlySubscription
       recurlySubscription
     )
     )
-    // Check if the user has missing billing details
-    await RecurlyClient.promises.getPaymentMethod(userId)
     await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
     await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
       subscription
       subscription
     )
     )
 
 
+    const { variant: flexibleLicensingForManuallyBilledSubscriptionsVariant } =
+      await SplitTestHandler.promises.getAssignment(
+        req,
+        res,
+        'flexible-group-licensing-for-manually-billed-subscriptions'
+      )
+
+    if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') {
+      await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
+        recurlySubscription,
+        userId
+      )
+    } else {
+      await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
+        recurlySubscription
+      )
+      // Check if the user has missing billing details
+      await RecurlyClient.promises.getPaymentMethod(userId)
+    }
+
     res.render('subscriptions/add-seats', {
     res.render('subscriptions/add-seats', {
       subscriptionId: subscription._id,
       subscriptionId: subscription._id,
       groupName: subscription.teamName,
       groupName: subscription.teamName,
       totalLicenses: subscription.membersLimit,
       totalLicenses: subscription.membersLimit,
       isProfessional: isProfessionalGroupPlan(subscription),
       isProfessional: isProfessionalGroupPlan(subscription),
+      isCollectionMethodManual: recurlySubscription.isCollectionMethodManual,
     })
     })
   } catch (error) {
   } catch (error) {
     if (error instanceof MissingBillingInfoError) {
     if (error instanceof MissingBillingInfoError) {
@@ -231,7 +248,8 @@ async function createAddSeatsSubscriptionChange(req, res) {
     const create =
     const create =
       await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
       await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
         userId,
         userId,
-        req.body.adding
+        req.body.adding,
+        req.body.poNumber
       )
       )
 
 
     res.json(create)
     res.json(create)
@@ -264,11 +282,27 @@ async function createAddSeatsSubscriptionChange(req, res) {
 async function submitForm(req, res) {
 async function submitForm(req, res) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const userId = SessionManager.getLoggedInUserId(req.session)
   const userEmail = await UserGetter.promises.getUserEmail(userId)
   const userEmail = await UserGetter.promises.getUserEmail(userId)
-  const { adding } = req.body
+  const { adding, poNumber } = req.body
+
+  const { recurlySubscription } =
+    await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
+      userId
+    )
+
+  if (recurlySubscription.isCollectionMethodManual) {
+    await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
+      userId,
+      recurlySubscription,
+      poNumber
+    )
+  }
 
 
   const messageLines = [`\n**Overleaf Sales Contact Form:**`]
   const messageLines = [`\n**Overleaf Sales Contact Form:**`]
   messageLines.push('**Subject:** Self-Serve Group User Increase Request')
   messageLines.push('**Subject:** Self-Serve Group User Increase Request')
   messageLines.push(`**Estimated Number of Users:** ${adding}`)
   messageLines.push(`**Estimated Number of Users:** ${adding}`)
+  if (poNumber) {
+    messageLines.push(`**PO Number:** ${poNumber}`)
+  }
   messageLines.push(
   messageLines.push(
     `**Message:** This email has been generated on behalf of user with email **${userEmail}** ` +
     `**Message:** This email has been generated on behalf of user with email **${userEmail}** ` +
       'to request an increase in the total number of users for their subscription.'
       'to request an increase in the total number of users for their subscription.'

+ 43 - 5
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js

@@ -7,6 +7,7 @@ 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 GroupPlansData = require('./GroupPlansData')
+const Modules = require('../../infrastructure/Modules')
 const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities')
 const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities')
 const {
 const {
   ManuallyCollectedError,
   ManuallyCollectedError,
@@ -122,13 +123,21 @@ async function getUsersGroupSubscriptionDetails(userId) {
   }
   }
 }
 }
 
 
+async function checkBillingInfoExistence(recurlySubscription, userId) {
+  // Verify the billing info only if the collection method is not manual (e.g. automatic)
+  if (!recurlySubscription.isCollectionMethodManual) {
+    // Check if the user has missing billing details
+    await RecurlyClient.promises.getPaymentMethod(userId)
+  }
+}
+
 async function _addSeatsSubscriptionChange(userId, adding) {
 async function _addSeatsSubscriptionChange(userId, adding) {
   const { subscription, recurlySubscription, plan } =
   const { subscription, recurlySubscription, plan } =
     await getUsersGroupSubscriptionDetails(userId)
     await getUsersGroupSubscriptionDetails(userId)
   await ensureFlexibleLicensingEnabled(plan)
   await ensureFlexibleLicensingEnabled(plan)
   await ensureSubscriptionIsActive(subscription)
   await ensureSubscriptionIsActive(subscription)
-  await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription)
   await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
   await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
+  await checkBillingInfoExistence(recurlySubscription, userId)
 
 
   const currentAddonQuantity =
   const currentAddonQuantity =
     recurlySubscription.addOns.find(
     recurlySubscription.addOns.find(
@@ -202,7 +211,6 @@ function _shouldUseLegacyPricing(
 async function previewAddSeatsSubscriptionChange(userId, adding) {
 async function previewAddSeatsSubscriptionChange(userId, adding) {
   const { changeRequest, currentAddonQuantity } =
   const { changeRequest, currentAddonQuantity } =
     await _addSeatsSubscriptionChange(userId, adding)
     await _addSeatsSubscriptionChange(userId, adding)
-  const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
   const subscriptionChange =
   const subscriptionChange =
     await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
     await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
   const subscriptionChangePreview =
   const subscriptionChangePreview =
@@ -217,16 +225,20 @@ async function previewAddSeatsSubscriptionChange(userId, adding) {
           prevQuantity: currentAddonQuantity,
           prevQuantity: currentAddonQuantity,
         },
         },
       },
       },
-      subscriptionChange,
-      paymentMethod
+      subscriptionChange
     )
     )
 
 
   return subscriptionChangePreview
   return subscriptionChangePreview
 }
 }
 
 
-async function createAddSeatsSubscriptionChange(userId, adding) {
+async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
   const { changeRequest, recurlySubscription } =
   const { changeRequest, recurlySubscription } =
     await _addSeatsSubscriptionChange(userId, adding)
     await _addSeatsSubscriptionChange(userId, adding)
+
+  if (recurlySubscription.isCollectionMethodManual) {
+    await updateSubscriptionPaymentTerms(userId, recurlySubscription, poNumber)
+  }
+
   await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
   await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
   await SubscriptionHandler.promises.syncSubscription(
   await SubscriptionHandler.promises.syncSubscription(
     { uuid: recurlySubscription.id },
     { uuid: recurlySubscription.id },
@@ -236,6 +248,29 @@ async function createAddSeatsSubscriptionChange(userId, adding) {
   return { adding }
   return { adding }
 }
 }
 
 
+async function updateSubscriptionPaymentTerms(
+  userId,
+  recurlySubscription,
+  poNumber
+) {
+  const countryCode = await RecurlyClient.promises.getCountryCode(userId)
+  const [termsAndConditions] = await Modules.promises.hooks.fire(
+    'generateTermsAndConditions',
+    { countryCode, poNumber }
+  )
+
+  const updateRequest = poNumber
+    ? recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
+        poNumber,
+        termsAndConditions
+      )
+    : recurlySubscription.getRequestForTermsAndConditionsUpdate(
+        termsAndConditions
+      )
+
+  await RecurlyClient.promises.updateSubscriptionDetails(updateRequest)
+}
+
 async function _getUpgradeTargetPlanCodeMaybeThrow(subscription) {
 async function _getUpgradeTargetPlanCodeMaybeThrow(subscription) {
   if (
   if (
     subscription.planCode.includes('professional') ||
     subscription.planCode.includes('professional') ||
@@ -308,6 +343,7 @@ module.exports = {
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
   upgradeGroupPlan: callbackify(upgradeGroupPlan),
   upgradeGroupPlan: callbackify(upgradeGroupPlan),
+  checkBillingInfoExistence: callbackify(checkBillingInfoExistence),
   promises: {
   promises: {
     removeUserFromGroup,
     removeUserFromGroup,
     replaceUserReferencesInGroups,
     replaceUserReferencesInGroups,
@@ -320,7 +356,9 @@ module.exports = {
     getUsersGroupSubscriptionDetails,
     getUsersGroupSubscriptionDetails,
     previewAddSeatsSubscriptionChange,
     previewAddSeatsSubscriptionChange,
     createAddSeatsSubscriptionChange,
     createAddSeatsSubscriptionChange,
+    updateSubscriptionPaymentTerms,
     getGroupPlanUpgradePreview,
     getGroupPlanUpgradePreview,
     upgradeGroupPlan,
     upgradeGroupPlan,
+    checkBillingInfoExistence,
   },
   },
 }
 }

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

@@ -23,6 +23,7 @@ const MAX_NUMBER_OF_USERS = 20
 const addSeatsValidateSchema = {
 const addSeatsValidateSchema = {
   body: Joi.object({
   body: Joi.object({
     adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
     adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
+    poNumber: Joi.string(),
   }),
   }),
 }
 }
 
 
@@ -90,6 +91,7 @@ export default {
       validate({
       validate({
         body: Joi.object({
         body: Joi.object({
           adding: Joi.number().integer().min(MAX_NUMBER_OF_USERS).required(),
           adding: Joi.number().integer().min(MAX_NUMBER_OF_USERS).required(),
+          poNumber: Joi.string(),
         }),
         }),
       }),
       }),
       RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
       RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),

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

@@ -4,11 +4,11 @@ block entrypointVar
 	- entrypoint = 'pages/user/subscription/group-management/add-seats'
 	- entrypoint = 'pages/user/subscription/group-management/add-seats'
 
 
 block append meta
 block append meta
-	meta(name="ol-subscriptionData" data-type="json" content=subscriptionData)
 	meta(name="ol-groupName", data-type="string", content=groupName)
 	meta(name="ol-groupName", data-type="string", content=groupName)
 	meta(name="ol-subscriptionId", data-type="string", content=subscriptionId)
 	meta(name="ol-subscriptionId", data-type="string", content=subscriptionId)
 	meta(name="ol-totalLicenses", data-type="number", content=totalLicenses)
 	meta(name="ol-totalLicenses", data-type="number", content=totalLicenses)
 	meta(name="ol-isProfessional", data-type="boolean", content=isProfessional)
 	meta(name="ol-isProfessional", data-type="boolean", content=isProfessional)
+	meta(name="ol-isCollectionMethodManual", data-type="boolean", content=isCollectionMethodManual)
 
 
 block content
 block content
 	main.content.content-alt#main-content
 	main.content.content-alt#main-content

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

@@ -757,6 +757,7 @@
   "how_we_use_your_data": "",
   "how_we_use_your_data": "",
   "how_we_use_your_data_explanation": "",
   "how_we_use_your_data_explanation": "",
   "i_confirm_am_student": "",
   "i_confirm_am_student": "",
+  "i_want_to_add_a_po_number": "",
   "i_want_to_stay": "",
   "i_want_to_stay": "",
   "id": "",
   "id": "",
   "identify_errors_with_your_compile": "",
   "identify_errors_with_your_compile": "",
@@ -1226,6 +1227,7 @@
   "plus_additional_collaborators_document_history_track_changes_and_more": "",
   "plus_additional_collaborators_document_history_track_changes_and_more": "",
   "plus_more": "",
   "plus_more": "",
   "plus_x_additional_licenses_for_a_total_of_y_licenses": "",
   "plus_x_additional_licenses_for_a_total_of_y_licenses": "",
+  "po_number": "",
   "postal_code": "",
   "postal_code": "",
   "premium": "",
   "premium": "",
   "premium_feature": "",
   "premium_feature": "",

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

@@ -16,6 +16,7 @@ import {
 } from 'react-bootstrap-5'
 } from 'react-bootstrap-5'
 import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
 import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
 import Button from '@/features/ui/components/bootstrap-5/button'
 import Button from '@/features/ui/components/bootstrap-5/button'
+import PoNumber from '@/features/group-management/components/add-seats/po-number'
 import CostSummary from '@/features/group-management/components/add-seats/cost-summary'
 import CostSummary from '@/features/group-management/components/add-seats/cost-summary'
 import RequestStatus from '@/features/group-management/components/request-status'
 import RequestStatus from '@/features/group-management/components/request-status'
 import useAsync from '@/shared/hooks/use-async'
 import useAsync from '@/shared/hooks/use-async'
@@ -29,6 +30,7 @@ import {
 } from '../../../../../../types/subscription/subscription-change-preview'
 } from '../../../../../../types/subscription/subscription-change-preview'
 import { MergeAndOverride, Nullable } from '../../../../../../types/utils'
 import { MergeAndOverride, Nullable } from '../../../../../../types/utils'
 import { sendMB } from '../../../../infrastructure/event-tracking'
 import { sendMB } from '../../../../infrastructure/event-tracking'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 
 export const MAX_NUMBER_OF_USERS = 20
 export const MAX_NUMBER_OF_USERS = 20
 
 
@@ -43,8 +45,12 @@ function AddSeats() {
   const subscriptionId = getMeta('ol-subscriptionId')
   const subscriptionId = getMeta('ol-subscriptionId')
   const totalLicenses = Number(getMeta('ol-totalLicenses'))
   const totalLicenses = Number(getMeta('ol-totalLicenses'))
   const isProfessional = getMeta('ol-isProfessional')
   const isProfessional = getMeta('ol-isProfessional')
+  const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual')
   const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
   const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
   const [shouldContactSales, setShouldContactSales] = useState(false)
   const [shouldContactSales, setShouldContactSales] = useState(false)
+  const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag(
+    'flexible-group-licensing-for-manually-billed-subscriptions'
+  )
   const controller = useAbortController()
   const controller = useAbortController()
   const { signal: addSeatsSignal } = useAbortController()
   const { signal: addSeatsSignal } = useAbortController()
   const { signal: contactSalesSignal } = useAbortController()
   const { signal: contactSalesSignal } = useAbortController()
@@ -151,6 +157,9 @@ function AddSeats() {
       formData.get('seats') === ''
       formData.get('seats') === ''
         ? undefined
         ? undefined
         : (formData.get('seats') as string)
         : (formData.get('seats') as string)
+    const poNumber = !formData.get('po_number')
+      ? undefined
+      : (formData.get('po_number') as string)
 
 
     if (!(await validateSeats(rawSeats))) {
     if (!(await validateSeats(rawSeats))) {
       return
       return
@@ -166,6 +175,7 @@ function AddSeats() {
           signal: contactSalesSignal,
           signal: contactSalesSignal,
           body: {
           body: {
             adding: rawSeats,
             adding: rawSeats,
+            poNumber,
           },
           },
         }
         }
       )
       )
@@ -176,7 +186,10 @@ function AddSeats() {
       })
       })
       const post = postJSON('/user/subscription/group/add-users/create', {
       const post = postJSON('/user/subscription/group/add-users/create', {
         signal: addSeatsSignal,
         signal: addSeatsSignal,
-        body: { adding: Number(rawSeats) },
+        body: {
+          adding: Number(rawSeats),
+          poNumber,
+        },
       })
       })
       runAsyncAddSeats(post)
       runAsyncAddSeats(post)
         .then(() => {
         .then(() => {
@@ -323,6 +336,8 @@ function AddSeats() {
                       <FormText type="error">{addSeatsInputError}</FormText>
                       <FormText type="error">{addSeatsInputError}</FormText>
                     )}
                     )}
                   </FormGroup>
                   </FormGroup>
+                  {isFlexibleGroupLicensingForManuallyBilledSubscriptions &&
+                    isCollectionMethodManual && <PoNumber />}
                 </div>
                 </div>
                 <CostSummarySection
                 <CostSummarySection
                   isLoadingCostSummary={isLoadingCostSummary}
                   isLoadingCostSummary={isLoadingCostSummary}

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

@@ -0,0 +1,30 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { FormControl, FormGroup, FormLabel } from 'react-bootstrap-5'
+import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
+
+function PoNumber() {
+  const { t } = useTranslation()
+  const [isPoNumberChecked, setIsPoNumberChecked] = useState(false)
+
+  return (
+    <>
+      <FormGroup className="mt-3">
+        <OLFormCheckbox
+          label={t('i_want_to_add_a_po_number')}
+          id="po-number-checkbox"
+          checked={isPoNumberChecked}
+          onChange={e => setIsPoNumberChecked(e.target.checked)}
+        />
+      </FormGroup>
+      {isPoNumberChecked && (
+        <FormGroup className="mt-2" controlId="po-number">
+          <FormLabel>{t('po_number')}</FormLabel>
+          <FormControl type="text" required className="w-25" name="po_number" />
+        </FormGroup>
+      )}
+    </>
+  )
+}
+
+export default PoNumber

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

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

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

@@ -129,6 +129,7 @@ export interface Meta {
   'ol-institutionLinked': InstitutionLink | undefined
   'ol-institutionLinked': InstitutionLink | undefined
   'ol-inviteToken': string
   'ol-inviteToken': string
   'ol-inviterName': string
   'ol-inviterName': string
+  'ol-isCollectionMethodManual': boolean
   'ol-isExternalAuthenticationSystemUsed': boolean
   'ol-isExternalAuthenticationSystemUsed': boolean
   'ol-isManagedAccount': boolean
   'ol-isManagedAccount': boolean
   'ol-isPaywallChangeCompileTimeoutEnabled': boolean
   'ol-isPaywallChangeCompileTimeoutEnabled': boolean

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

@@ -982,6 +982,7 @@
   "how_we_use_your_data_explanation": "<0>Please help us continue to improve Overleaf by answering a few quick questions. Your answers will help us and our corporate group understand more about our user base. We may use this information to improve your Overleaf experience, for example by providing personalized onboarding, upgrade prompts, help suggestions, and tailored marketing communications (if you’ve opted-in to receive them).</0><1>For more details on how we use your personal data, please see our <0>Privacy Notice</0>.</1>",
   "how_we_use_your_data_explanation": "<0>Please help us continue to improve Overleaf by answering a few quick questions. Your answers will help us and our corporate group understand more about our user base. We may use this information to improve your Overleaf experience, for example by providing personalized onboarding, upgrade prompts, help suggestions, and tailored marketing communications (if you’ve opted-in to receive them).</0><1>For more details on how we use your personal data, please see our <0>Privacy Notice</0>.</1>",
   "hundreds_templates_info": "Produce beautiful documents starting from our gallery of LaTeX templates for journals, conferences, theses, reports, CVs and much more.",
   "hundreds_templates_info": "Produce beautiful documents starting from our gallery of LaTeX templates for journals, conferences, theses, reports, CVs and much more.",
   "i_confirm_am_student": "I confirm that I am currently a student.",
   "i_confirm_am_student": "I confirm that I am currently a student.",
+  "i_want_to_add_a_po_number": "I want to add a PO number",
   "i_want_to_stay": "I want to stay",
   "i_want_to_stay": "I want to stay",
   "id": "ID",
   "id": "ID",
   "identify_errors_with_your_compile": "Identify errors with your compile",
   "identify_errors_with_your_compile": "Identify errors with your compile",
@@ -1632,6 +1633,7 @@
   "plus_additional_collaborators_document_history_track_changes_and_more": "(plus additional collaborators, document history, track changes, and more).",
   "plus_additional_collaborators_document_history_track_changes_and_more": "(plus additional collaborators, document history, track changes, and more).",
   "plus_more": "plus more",
   "plus_more": "plus more",
   "plus_x_additional_licenses_for_a_total_of_y_licenses": "Plus <0>__additionalLicenses__</0> additional license(s) for a total of <1>__count__ licenses</1>",
   "plus_x_additional_licenses_for_a_total_of_y_licenses": "Plus <0>__additionalLicenses__</0> additional license(s) for a total of <1>__count__ licenses</1>",
+  "po_number": "PO Number",
   "popular_tags": "Popular Tags",
   "popular_tags": "Popular Tags",
   "portal_add_affiliation_to_join": "It looks like you are already logged in to __appName__. If you have a __portalTitle__ email you can add it now.",
   "portal_add_affiliation_to_join": "It looks like you are already logged in to __appName__. If you have a __portalTitle__ email you can add it now.",
   "position": "Position",
   "position": "Position",

+ 47 - 5
services/web/test/frontend/features/group-management/components/add-seats.spec.tsx

@@ -1,6 +1,7 @@
 import AddSeats, {
 import AddSeats, {
   MAX_NUMBER_OF_USERS,
   MAX_NUMBER_OF_USERS,
 } from '@/features/group-management/components/add-seats/add-seats'
 } from '@/features/group-management/components/add-seats/add-seats'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
 
 
 describe('<AddSeats />', function () {
 describe('<AddSeats />', function () {
   beforeEach(function () {
   beforeEach(function () {
@@ -11,9 +12,17 @@ describe('<AddSeats />', function () {
       win.metaAttributesCache.set('ol-subscriptionId', '123')
       win.metaAttributesCache.set('ol-subscriptionId', '123')
       win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
       win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
       win.metaAttributesCache.set('ol-isProfessional', false)
       win.metaAttributesCache.set('ol-isProfessional', false)
+      win.metaAttributesCache.set('ol-isCollectionMethodManual', true)
+      win.metaAttributesCache.set('ol-splitTestVariants', {
+        'flexible-group-licensing-for-manually-billed-subscriptions': 'enabled',
+      })
     })
     })
 
 
-    cy.mount(<AddSeats />)
+    cy.mount(
+      <SplitTestProvider>
+        <AddSeats />
+      </SplitTestProvider>
+    )
 
 
     cy.findByRole('button', { name: /buy licenses/i })
     cy.findByRole('button', { name: /buy licenses/i })
     cy.findByTestId('add-more-users-group-form')
     cy.findByTestId('add-more-users-group-form')
@@ -68,6 +77,28 @@ describe('<AddSeats />', function () {
     )
     )
   })
   })
 
 
+  describe('PO number', function () {
+    it('should not render the PO checkbox and PO input if collection method is not manual', function () {
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-isCollectionMethodManual', false)
+      })
+      cy.mount(
+        <SplitTestProvider>
+          <AddSeats />
+        </SplitTestProvider>
+      )
+
+      cy.findByLabelText(/i want to add a po number/i).should('not.exist')
+      cy.findByLabelText(/^po number$/i).should('not.exist')
+    })
+
+    it('should check the PO checkbox in order to activate the PO input field', function () {
+      cy.findByLabelText(/^po number$/i).should('not.exist')
+      cy.findByLabelText(/i want to add a po number/i).check()
+      cy.findByLabelText(/^po number$/i)
+    })
+  })
+
   describe('"Upgrade my plan" link', function () {
   describe('"Upgrade my plan" link', function () {
     it('shows the link', function () {
     it('shows the link', function () {
       cy.findByRole('link', { name: /upgrade my plan/i }).should(
       cy.findByRole('link', { name: /upgrade my plan/i }).should(
@@ -82,7 +113,11 @@ describe('<AddSeats />', function () {
         win.metaAttributesCache.set('ol-isProfessional', true)
         win.metaAttributesCache.set('ol-isProfessional', true)
       })
       })
 
 
-      cy.mount(<AddSeats />)
+      cy.mount(
+        <SplitTestProvider>
+          <AddSeats />
+        </SplitTestProvider>
+      )
 
 
       cy.findByRole('link', { name: /upgrade my plan/i }).should('not.exist')
       cy.findByRole('link', { name: /upgrade my plan/i }).should('not.exist')
     })
     })
@@ -325,16 +360,23 @@ describe('<AddSeats />', function () {
         })
         })
 
 
         function makeRequest(statusCode: number, adding: string) {
         function makeRequest(statusCode: number, adding: string) {
+          const PO_NUMBER = 'PO123456789'
           cy.intercept('POST', '/user/subscription/group/add-users/create', {
           cy.intercept('POST', '/user/subscription/group/add-users/create', {
             statusCode,
             statusCode,
           }).as('addUsersRequest')
           }).as('addUsersRequest')
           cy.get('@input').type(adding)
           cy.get('@input').type(adding)
+          cy.findByLabelText(/i want to add a po number/i).check()
+          cy.findByLabelText(/^po number$/i).type(PO_NUMBER)
           cy.get('@addUsersBtn').click()
           cy.get('@addUsersBtn').click()
+
+          const body = {
+            adding: Number(adding),
+            poNumber: PO_NUMBER,
+          }
           cy.get('@addUsersRequest')
           cy.get('@addUsersRequest')
             .its('request.body')
             .its('request.body')
-            .should('deep.equal', {
-              adding: Number(adding),
-            })
+            .should('deep.equal', body)
+            .and('have.keys', Object.keys(body))
           cy.findByTestId('add-more-users-group-form').should('not.exist')
           cy.findByTestId('add-more-users-group-form').should('not.exist')
         }
         }
 
 

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

@@ -6,6 +6,7 @@ const Errors = require('../../../../app/src/Features/Subscription/Errors')
 const {
 const {
   AI_ADD_ON_CODE,
   AI_ADD_ON_CODE,
   PaymentProviderSubscriptionChangeRequest,
   PaymentProviderSubscriptionChangeRequest,
+  PaymentProviderSubscriptionUpdateRequest,
   PaymentProviderSubscriptionChange,
   PaymentProviderSubscriptionChange,
   PaymentProviderSubscription,
   PaymentProviderSubscription,
   PaymentProviderSubscriptionAddOnUpdate,
   PaymentProviderSubscriptionAddOnUpdate,
@@ -307,6 +308,36 @@ describe('PaymentProviderEntities', function () {
         })
         })
       })
       })
 
 
+      describe('getRequestForPoNumberAndTermsAndConditionsUpdate()', function () {
+        it('returns a correct update request', function () {
+          const updateRequest =
+            this.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
+              'O12345',
+              'T&C copy'
+            )
+          expect(updateRequest).to.deep.equal(
+            new PaymentProviderSubscriptionUpdateRequest({
+              subscription: this.subscription,
+              poNumber: 'O12345',
+              termsAndConditions: 'T&C copy',
+            })
+          )
+        })
+      })
+
+      describe('getRequestForTermsAndConditionsUpdate()', function () {
+        it('returns a correct update request', function () {
+          const updateRequest =
+            this.subscription.getRequestForTermsAndConditionsUpdate('T&C copy')
+          expect(updateRequest).to.deep.equal(
+            new PaymentProviderSubscriptionUpdateRequest({
+              subscription: this.subscription,
+              termsAndConditions: 'T&C copy',
+            })
+          )
+        })
+      })
+
       describe('without add-ons', function () {
       describe('without add-ons', function () {
         beforeEach(function () {
         beforeEach(function () {
           const { PaymentProviderSubscription } = this.PaymentProviderEntities
           const { PaymentProviderSubscription } = this.PaymentProviderEntities
@@ -381,6 +412,8 @@ describe('PaymentProviderEntities', function () {
           periodStart: new Date(),
           periodStart: new Date(),
           periodEnd: new Date(),
           periodEnd: new Date(),
           collectionMethod: 'automatic',
           collectionMethod: 'automatic',
+          poNumber: '012345',
+          termsAndConditions: 'T&C copy',
         })
         })
         const change = new PaymentProviderSubscriptionChange({
         const change = new PaymentProviderSubscriptionChange({
           subscription,
           subscription,

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

@@ -5,6 +5,7 @@ const SandboxedModule = require('sandboxed-module')
 const {
 const {
   PaymentProviderSubscription,
   PaymentProviderSubscription,
   PaymentProviderSubscriptionChangeRequest,
   PaymentProviderSubscriptionChangeRequest,
+  PaymentProviderSubscriptionUpdateRequest,
   PaymentProviderSubscriptionAddOnUpdate,
   PaymentProviderSubscriptionAddOnUpdate,
   PaymentProviderAccount,
   PaymentProviderAccount,
   PaymentProviderCoupon,
   PaymentProviderCoupon,
@@ -57,6 +58,8 @@ describe('RecurlyClient', function () {
       periodStart: new Date(),
       periodStart: new Date(),
       periodEnd: new Date(),
       periodEnd: new Date(),
       collectionMethod: 'automatic',
       collectionMethod: 'automatic',
+      poNumber: '',
+      termsAndConditions: '',
     })
     })
 
 
     this.recurlySubscription = {
     this.recurlySubscription = {
@@ -87,6 +90,8 @@ describe('RecurlyClient', function () {
       currentPeriodStartedAt: this.subscription.periodStart,
       currentPeriodStartedAt: this.subscription.periodStart,
       currentPeriodEndsAt: this.subscription.periodEnd,
       currentPeriodEndsAt: this.subscription.periodEnd,
       collectionMethod: this.subscription.collectionMethod,
       collectionMethod: this.subscription.collectionMethod,
+      poNumber: this.subscription.poNumber,
+      termsAndConditions: this.subscription.termsAndConditions,
     }
     }
 
 
     this.recurlySubscriptionChange = new recurly.SubscriptionChange()
     this.recurlySubscriptionChange = new recurly.SubscriptionChange()
@@ -444,6 +449,37 @@ describe('RecurlyClient', function () {
     })
     })
   })
   })
 
 
+  describe('updateSubscriptionDetails', function () {
+    beforeEach(function () {
+      this.client.updateSubscription = sinon
+        .stub()
+        .resolves({ id: this.subscription.id })
+    })
+
+    it('handles subscription update', async function () {
+      await this.RecurlyClient.promises.updateSubscriptionDetails(
+        new PaymentProviderSubscriptionUpdateRequest({
+          subscription: this.subscription,
+          poNumber: '012345',
+          termsAndConditions: 'T&C',
+        })
+      )
+      expect(this.client.updateSubscription).to.be.calledWith(
+        'uuid-subscription-id',
+        { poNumber: '012345', termsAndConditions: 'T&C' }
+      )
+    })
+
+    it('should throw any API errors', async function () {
+      this.client.updateSubscription = sinon.stub().throws()
+      await expect(
+        this.RecurlyClient.promises.updateSubscriptionDetails({
+          subscription: this.subscription,
+        })
+      ).to.eventually.be.rejectedWith(Error)
+    })
+  })
+
   describe('removeSubscriptionChange', function () {
   describe('removeSubscriptionChange', function () {
     beforeEach(function () {
     beforeEach(function () {
       this.client.removeSubscriptionChange = sinon.stub().resolves()
       this.client.removeSubscriptionChange = sinon.stub().resolves()
@@ -654,4 +690,29 @@ describe('RecurlyClient', function () {
       ).to.be.rejectedWith(Error)
       ).to.be.rejectedWith(Error)
     })
     })
   })
   })
+
+  describe('getCountryCode', function () {
+    it('should return the country code from the account info', async function () {
+      this.client.getAccount = sinon.stub().resolves({
+        address: {
+          country: 'GB',
+        },
+      })
+      const countryCode = await this.RecurlyClient.promises.getCountryCode(
+        this.user._id
+      )
+      expect(countryCode).to.equal('GB')
+    })
+
+    it('should throw if country code doesn’t exist', async function () {
+      this.client.getAccount = sinon.stub().resolves({
+        address: {
+          country: '',
+        },
+      })
+      await expect(
+        this.RecurlyClient.promises.getCountryCode(this.user._id)
+      ).to.be.rejectedWith(Error, 'Country code not found')
+    })
+  })
 })
 })

+ 29 - 21
services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs

@@ -34,6 +34,12 @@ describe('SubscriptionGroupController', function () {
       canUseFlexibleLicensing: true,
       canUseFlexibleLicensing: true,
     }
     }
 
 
+    this.recurlySubscription = {
+      get isCollectionMethodManual() {
+        return true
+      },
+    }
+
     this.previewSubscriptionChangeData = {
     this.previewSubscriptionChangeData = {
       change: {},
       change: {},
       currency: 'USD',
       currency: 'USD',
@@ -41,13 +47,15 @@ describe('SubscriptionGroupController', function () {
 
 
     this.createSubscriptionChangeData = { adding: 1 }
     this.createSubscriptionChangeData = { adding: 1 }
 
 
+    this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
+
     this.SubscriptionGroupHandler = {
     this.SubscriptionGroupHandler = {
       promises: {
       promises: {
         removeUserFromGroup: sinon.stub().resolves(),
         removeUserFromGroup: sinon.stub().resolves(),
         getUsersGroupSubscriptionDetails: sinon.stub().resolves({
         getUsersGroupSubscriptionDetails: sinon.stub().resolves({
           subscription: this.subscription,
           subscription: this.subscription,
           plan: this.plan,
           plan: this.plan,
-          recurlySubscription: {},
+          recurlySubscription: this.recurlySubscription,
         }),
         }),
         previewAddSeatsSubscriptionChange: sinon
         previewAddSeatsSubscriptionChange: sinon
           .stub()
           .stub()
@@ -62,6 +70,8 @@ describe('SubscriptionGroupController', function () {
         getGroupPlanUpgradePreview: sinon
         getGroupPlanUpgradePreview: sinon
           .stub()
           .stub()
           .resolves(this.previewSubscriptionChangeData),
           .resolves(this.previewSubscriptionChangeData),
+        checkBillingInfoExistence: sinon.stub().resolves(this.paymentMethod),
+        updateSubscriptionPaymentTerms: sinon.stub().resolves(),
       },
       },
     }
     }
 
 
@@ -96,7 +106,7 @@ describe('SubscriptionGroupController', function () {
 
 
     this.SplitTestHandler = {
     this.SplitTestHandler = {
       promises: {
       promises: {
-        getAssignment: sinon.stub().resolves(),
+        getAssignment: sinon.stub().resolves({ variant: 'enabled' }),
       },
       },
     }
     }
 
 
@@ -111,7 +121,6 @@ describe('SubscriptionGroupController', function () {
     this.RecurlyClient = {
     this.RecurlyClient = {
       promises: {
       promises: {
         getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
         getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
-        // getSubscription: sinon.stub().resolves(this.subscription),
       },
       },
     }
     }
 
 
@@ -355,14 +364,21 @@ 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.ensureSubscriptionHasNoPendingChanges
+            .calledWith(this.recurlySubscription)
+            .should.equal(true)
           this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
           this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
             .calledWith(this.subscription)
             .calledWith(this.subscription)
             .should.equal(true)
             .should.equal(true)
+          this.SubscriptionGroupHandler.promises.checkBillingInfoExistence
+            .calledWith(this.recurlySubscription, this.adminUserId)
+            .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)
           props.totalLicenses.should.equal(this.subscription.membersLimit)
           props.totalLicenses.should.equal(this.subscription.membersLimit)
           props.isProfessional.should.equal(false)
           props.isProfessional.should.equal(false)
+          props.isCollectionMethodManual.should.equal(true)
           done()
           done()
         },
         },
       }
       }
@@ -399,7 +415,7 @@ describe('SubscriptionGroupController', function () {
     })
     })
 
 
     it('should redirect to missing billing information page when billing information is missing', function (done) {
     it('should redirect to missing billing information page when billing information is missing', function (done) {
-      this.RecurlyClient.promises.getPaymentMethod = sinon
+      this.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon
         .stub()
         .stub()
         .throws(new this.Errors.MissingBillingInfoError())
         .throws(new this.Errors.MissingBillingInfoError())
 
 
@@ -415,22 +431,6 @@ describe('SubscriptionGroupController', function () {
       this.Controller.addSeatsToGroupSubscription(this.req, res)
       this.Controller.addSeatsToGroupSubscription(this.req, res)
     })
     })
 
 
-    it('should redirect to manually collected subscription error page when collection method is manual', function (done) {
-      this.SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual =
-        sinon.stub().throws(new this.Errors.ManuallyCollectedError())
-
-      const res = {
-        redirect: url => {
-          url.should.equal(
-            '/user/subscription/group/manually-collected-subscription'
-          )
-          done()
-        },
-      }
-
-      this.Controller.addSeatsToGroupSubscription(this.req, res)
-    })
-
     it('should redirect to subscription page when there is a pending change', function (done) {
     it('should redirect to subscription page when there is a pending change', function (done) {
       this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
       this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
         sinon.stub().throws(new this.Errors.PendingChangeError())
         sinon.stub().throws(new this.Errors.PendingChangeError())
@@ -586,10 +586,16 @@ describe('SubscriptionGroupController', function () {
   describe('submitForm', function () {
   describe('submitForm', function () {
     it('should build and pass the request body to the sales submit handler', function (done) {
     it('should build and pass the request body to the sales submit handler', function (done) {
       const adding = 100
       const adding = 100
-      this.req.body = { adding }
+      const poNumber = 'PO123456'
+      this.req.body = { adding, poNumber }
 
 
       const res = {
       const res = {
         sendStatus: code => {
         sendStatus: code => {
+          this.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
+            this.adminUserId,
+            this.recurlySubscription,
+            poNumber
+          )
           this.Modules.promises.hooks.fire
           this.Modules.promises.hooks.fire
             .calledWith('sendSupportRequest', {
             .calledWith('sendSupportRequest', {
               email: this.user.email,
               email: this.user.email,
@@ -602,6 +608,8 @@ describe('SubscriptionGroupController', function () {
                 '\n' +
                 '\n' +
                 `**Estimated Number of Users:** ${adding}\n` +
                 `**Estimated Number of Users:** ${adding}\n` +
                 '\n' +
                 '\n' +
+                `**PO Number:** ${poNumber}\n` +
+                '\n' +
                 `**Message:** This email has been generated on behalf of user with email **${this.user.email}** to request an increase in the total number of users for their subscription.`,
                 `**Message:** This email has been generated on behalf of user with email **${this.user.email}** to request an increase in the total number of users for their subscription.`,
               inbox: 'sales',
               inbox: 'sales',
             })
             })

+ 123 - 5
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -36,6 +36,15 @@ describe('SubscriptionGroupHandler', function () {
       },
       },
     }
     }
 
 
+    this.termsAndConditionsUpdate = {
+      termsAndConditions: 'T&C copy',
+    }
+
+    this.poNumberAndTermsAndConditionsUpdate = {
+      poNumber: '4444',
+      ...this.termsAndConditionsUpdate,
+    }
+
     this.recurlySubscription = {
     this.recurlySubscription = {
       id: 123,
       id: 123,
       addOns: [
       addOns: [
@@ -50,10 +59,19 @@ describe('SubscriptionGroupHandler', function () {
       getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
       getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
         .stub()
         .stub()
         .returns(this.changeRequest),
         .returns(this.changeRequest),
+      getRequestForPoNumberAndTermsAndConditionsUpdate: sinon
+        .stub()
+        .returns(this.poNumberAndTermsAndConditionsUpdate),
+      getRequestForTermsAndConditionsUpdate: sinon
+        .stub()
+        .returns(this.termsAndConditionsUpdate),
       currency: 'USD',
       currency: 'USD',
       hasAddOn(code) {
       hasAddOn(code) {
         return this.addOns.some(addOn => addOn.code === code)
         return this.addOns.some(addOn => addOn.code === code)
       },
       },
+      get isCollectionMethodManual() {
+        return false
+      },
     }
     }
 
 
     this.SubscriptionLocator = {
     this.SubscriptionLocator = {
@@ -119,6 +137,8 @@ describe('SubscriptionGroupHandler', function () {
         applySubscriptionChangeRequest: sinon
         applySubscriptionChangeRequest: sinon
           .stub()
           .stub()
           .resolves(this.applySubscriptionChange),
           .resolves(this.applySubscriptionChange),
+        getCountryCode: sinon.stub().resolves('BG'),
+        updateSubscriptionDetails: sinon.stub().resolves(),
       },
       },
     }
     }
 
 
@@ -155,6 +175,19 @@ describe('SubscriptionGroupHandler', function () {
       },
       },
     }
     }
 
 
+    this.Modules = {
+      promises: {
+        hooks: {
+          fire: sinon.stub().callsFake(hookName => {
+            if (hookName === 'generateTermsAndConditions') {
+              return Promise.resolve(['T&Cs'])
+            }
+            return Promise.resolve()
+          }),
+        },
+      },
+    }
+
     this.Handler = SandboxedModule.require(modulePath, {
     this.Handler = SandboxedModule.require(modulePath, {
       requires: {
       requires: {
         './SubscriptionUpdater': this.SubscriptionUpdater,
         './SubscriptionUpdater': this.SubscriptionUpdater,
@@ -169,6 +202,7 @@ describe('SubscriptionGroupHandler', function () {
         './PaymentProviderEntities': this.PaymentProviderEntities,
         './PaymentProviderEntities': this.PaymentProviderEntities,
         '../Authentication/SessionManager': this.SessionManager,
         '../Authentication/SessionManager': this.SessionManager,
         './GroupPlansData': this.GroupPlansData,
         './GroupPlansData': this.GroupPlansData,
+        '../../infrastructure/Modules': this.Modules,
       },
       },
     })
     })
   })
   })
@@ -398,8 +432,7 @@ describe('SubscriptionGroupHandler', function () {
                   prevQuantity: this.prevQuantity,
                   prevQuantity: this.prevQuantity,
                 },
                 },
               },
               },
-              this.previewSubscriptionChange,
-              this.paymentMethod
+              this.previewSubscriptionChange
             )
             )
             .should.equal(true)
             .should.equal(true)
           preview.should.equal(this.changePreview)
           preview.should.equal(this.changePreview)
@@ -408,12 +441,30 @@ describe('SubscriptionGroupHandler', function () {
 
 
       describe('createAddSeatsSubscriptionChange', function () {
       describe('createAddSeatsSubscriptionChange', function () {
         it('should change the subscription', async function () {
         it('should change the subscription', async function () {
+          this.recurlySubscription = {
+            ...this.recurlySubscription,
+            get isCollectionMethodManual() {
+              return true
+            },
+          }
+          this.RecurlyClient.promises.getSubscription = sinon
+            .stub()
+            .resolves(this.recurlySubscription)
+
           const result =
           const result =
             await this.Handler.promises.createAddSeatsSubscriptionChange(
             await this.Handler.promises.createAddSeatsSubscriptionChange(
               this.adminUser_id,
               this.adminUser_id,
-              this.adding
+              this.adding,
+              '123'
             )
             )
 
 
+          this.RecurlyClient.promises.updateSubscriptionDetails
+            .calledWith(
+              sinon.match
+                .has('poNumber')
+                .and(sinon.match.has('termsAndConditions'))
+            )
+            .should.equal(true)
           this.RecurlyClient.promises.applySubscriptionChangeRequest
           this.RecurlyClient.promises.applySubscriptionChangeRequest
             .calledWith(this.changeRequest)
             .calledWith(this.changeRequest)
             .should.equal(true)
             .should.equal(true)
@@ -430,6 +481,48 @@ describe('SubscriptionGroupHandler', function () {
       })
       })
     })
     })
 
 
+    describe('updateSubscriptionPaymentTerms', function () {
+      describe('accounts with PO number', function () {
+        it('should update the subscription PO number and T&C', async function () {
+          this.RecurlyClient.promises.getCountryCode = sinon
+            .stub()
+            .resolves('GB')
+          await this.Handler.promises.updateSubscriptionPaymentTerms(
+            this.adminUser_id,
+            this.recurlySubscription,
+            this.poNumberAndTermsAndConditionsUpdate.poNumber
+          )
+          this.recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate
+            .calledWithMatch(
+              this.poNumberAndTermsAndConditionsUpdate.poNumber,
+              'T&Cs'
+            )
+            .should.equal(true)
+          this.RecurlyClient.promises.updateSubscriptionDetails
+            .calledWith(this.poNumberAndTermsAndConditionsUpdate)
+            .should.equal(true)
+        })
+      })
+
+      describe('accounts with no PO number', function () {
+        it('should update the subscription T&C only', async function () {
+          this.RecurlyClient.promises.getCountryCode = sinon
+            .stub()
+            .resolves('GB')
+          await this.Handler.promises.updateSubscriptionPaymentTerms(
+            this.adminUser_id,
+            this.recurlySubscription
+          )
+          this.recurlySubscription.getRequestForTermsAndConditionsUpdate
+            .calledWithMatch('T&Cs')
+            .should.equal(true)
+          this.RecurlyClient.promises.updateSubscriptionDetails
+            .calledWith(this.termsAndConditionsUpdate)
+            .should.equal(true)
+        })
+      })
+    })
+
     describe('has no "additional-license" add-on', function () {
     describe('has no "additional-license" add-on', function () {
       beforeEach(function () {
       beforeEach(function () {
         this.recurlySubscription.addOns = []
         this.recurlySubscription.addOns = []
@@ -474,8 +567,7 @@ describe('SubscriptionGroupHandler', function () {
                   prevQuantity: this.prevQuantity,
                   prevQuantity: this.prevQuantity,
                 },
                 },
               },
               },
-              this.previewSubscriptionChange,
-              this.paymentMethod
+              this.previewSubscriptionChange
             )
             )
             .should.equal(true)
             .should.equal(true)
           preview.should.equal(this.changePreview)
           preview.should.equal(this.changePreview)
@@ -743,4 +835,30 @@ describe('SubscriptionGroupHandler', function () {
       result.should.equal(this.changePreview)
       result.should.equal(this.changePreview)
     })
     })
   })
   })
+
+  describe('checkBillingInfoExistence', function () {
+    it('should invoke the payment method function when collection method is "automatic"', async function () {
+      await this.Handler.promises.checkBillingInfoExistence(
+        this.recurlySubscription,
+        this.adminUser_id
+      )
+      this.RecurlyClient.promises.getPaymentMethod
+        .calledWith(this.adminUser_id)
+        .should.equal(true)
+    })
+
+    it('shouldn’t invoke the payment method function when collection method is "manual"', async function () {
+      const recurlySubscription = {
+        ...this.recurlySubscription,
+        get isCollectionMethodManual() {
+          return true
+        },
+      }
+      await this.Handler.promises.checkBillingInfoExistence(
+        recurlySubscription,
+        this.adminUser_id
+      )
+      this.RecurlyClient.promises.getPaymentMethod.should.not.have.been.called
+    })
+  })
 })
 })

+ 1 - 1
services/web/types/subscription/subscription-change-preview.ts

@@ -1,7 +1,7 @@
 export type SubscriptionChangePreview = {
 export type SubscriptionChangePreview = {
   change: SubscriptionChangeDescription
   change: SubscriptionChangeDescription
   currency: string
   currency: string
-  paymentMethod: string
+  paymentMethod: string | undefined
   nextPlan: {
   nextPlan: {
     annual: boolean
     annual: boolean
   }
   }