Sfoglia il codice sorgente

Merge pull request #26968 from overleaf/em-revert-jul8

Revert bad deploy

GitOrigin-RevId: fd6227cf4fde7fd8053b47365154d59d15fa115e
Eric Mc Sween 1 anno fa
parent
commit
ebf810e836
22 ha cambiato i file con 177 aggiunte e 292 eliminazioni
  1. 0 3
      services/web/app/src/Features/Subscription/Errors.js
  2. 32 13
      services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
  3. 0 22
      services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
  4. 2 5
      services/web/app/src/Features/Subscription/SubscriptionLocator.js
  5. 5 1
      services/web/app/src/Features/UserMembership/UserMembershipHandler.js
  6. 19 48
      services/web/app/src/Features/UserMembership/UserMembershipViewModel.js
  7. 3 3
      services/web/frontend/extracted-translations.json
  8. 11 6
      services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx
  9. 0 9
      services/web/frontend/js/features/group-management/components/group-members.tsx
  10. 22 7
      services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx
  11. 3 0
      services/web/frontend/js/features/group-management/components/members-table/managed-user-status.tsx
  12. 8 42
      services/web/frontend/js/features/group-management/components/members-table/members-list.tsx
  13. 1 14
      services/web/frontend/js/features/group-management/context/group-members-context.tsx
  14. 0 1
      services/web/frontend/js/utils/meta.ts
  15. 3 3
      services/web/locales/en.json
  16. 3 0
      services/web/test/frontend/features/group-management/components/add-seats.spec.tsx
  17. 22 0
      services/web/test/frontend/features/group-management/components/members-table/managed-user-status.spec.tsx
  18. 0 10
      services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx
  19. 13 28
      services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs
  20. 0 43
      services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
  21. 15 13
      services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js
  22. 15 21
      services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js

+ 0 - 3
services/web/app/src/Features/Subscription/Errors.js

@@ -26,8 +26,6 @@ class SubtotalLimitExceededError extends OError {}
 
 
 class HasPastDueInvoiceError extends OError {}
 class HasPastDueInvoiceError extends OError {}
 
 
-class HasNoAdditionalLicenseWhenManuallyCollectedError extends OError {}
-
 class PaymentActionRequiredError extends OError {
 class PaymentActionRequiredError extends OError {
   constructor(info) {
   constructor(info) {
     super('Payment action required', info)
     super('Payment action required', info)
@@ -45,5 +43,4 @@ module.exports = {
   InactiveError,
   InactiveError,
   SubtotalLimitExceededError,
   SubtotalLimitExceededError,
   HasPastDueInvoiceError,
   HasPastDueInvoiceError,
-  HasNoAdditionalLicenseWhenManuallyCollectedError,
 }
 }

+ 32 - 13
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'
@@ -18,7 +19,6 @@ import {
   InactiveError,
   InactiveError,
   SubtotalLimitExceededError,
   SubtotalLimitExceededError,
   HasPastDueInvoiceError,
   HasPastDueInvoiceError,
-  HasNoAdditionalLicenseWhenManuallyCollectedError,
 } from './Errors.js'
 } from './Errors.js'
 
 
 /**
 /**
@@ -151,13 +151,26 @@ async function addSeatsToGroupSubscription(req, res) {
     await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice(
     await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice(
       subscription
       subscription
     )
     )
-    await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
-      paymentProviderSubscription,
-      userId
-    )
-    await SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
-      paymentProviderSubscription
-    )
+
+    const { variant: flexibleLicensingForManuallyBilledSubscriptionsVariant } =
+      await SplitTestHandler.promises.getAssignment(
+        req,
+        res,
+        'flexible-group-licensing-for-manually-billed-subscriptions'
+      )
+
+    if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') {
+      await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
+        paymentProviderSubscription,
+        userId
+      )
+    } else {
+      await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
+        paymentProviderSubscription
+      )
+      // Check if the user has missing billing details
+      await Modules.promises.hooks.fire('getPaymentMethod', userId)
+    }
 
 
     res.render('subscriptions/add-seats', {
     res.render('subscriptions/add-seats', {
       subscriptionId: subscription._id,
       subscriptionId: subscription._id,
@@ -174,7 +187,7 @@ async function addSeatsToGroupSubscription(req, res) {
       )
       )
     }
     }
 
 
-    if (error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError) {
+    if (error instanceof ManuallyCollectedError) {
       return res.redirect(
       return res.redirect(
         '/user/subscription/group/manually-collected-subscription'
         '/user/subscription/group/manually-collected-subscription'
       )
       )
@@ -215,10 +228,10 @@ async function previewAddSeatsSubscriptionChange(req, res) {
   } catch (error) {
   } catch (error) {
     if (
     if (
       error instanceof MissingBillingInfoError ||
       error instanceof MissingBillingInfoError ||
+      error instanceof ManuallyCollectedError ||
       error instanceof PendingChangeError ||
       error instanceof PendingChangeError ||
       error instanceof InactiveError ||
       error instanceof InactiveError ||
-      error instanceof HasPastDueInvoiceError ||
-      error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError
+      error instanceof HasPastDueInvoiceError
     ) {
     ) {
       return res.status(422).end()
       return res.status(422).end()
     }
     }
@@ -258,10 +271,10 @@ async function createAddSeatsSubscriptionChange(req, res) {
   } catch (error) {
   } catch (error) {
     if (
     if (
       error instanceof MissingBillingInfoError ||
       error instanceof MissingBillingInfoError ||
+      error instanceof ManuallyCollectedError ||
       error instanceof PendingChangeError ||
       error instanceof PendingChangeError ||
       error instanceof InactiveError ||
       error instanceof InactiveError ||
-      error instanceof HasPastDueInvoiceError ||
-      error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError
+      error instanceof HasPastDueInvoiceError
     ) {
     ) {
       return res.status(422).end()
       return res.status(422).end()
     }
     }
@@ -398,6 +411,12 @@ async function manuallyCollectedSubscription(req, res) {
     const subscription =
     const subscription =
       await SubscriptionLocator.promises.getUsersSubscription(userId)
       await SubscriptionLocator.promises.getUsersSubscription(userId)
 
 
+    await SplitTestHandler.promises.getAssignment(
+      req,
+      res,
+      'flexible-group-licensing-for-manually-billed-subscriptions'
+    )
+
     res.render('subscriptions/manually-collected-subscription', {
     res.render('subscriptions/manually-collected-subscription', {
       groupName: subscription.teamName,
       groupName: subscription.teamName,
     })
     })

+ 0 - 22
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js

@@ -18,7 +18,6 @@ const {
   PendingChangeError,
   PendingChangeError,
   InactiveError,
   InactiveError,
   HasPastDueInvoiceError,
   HasPastDueInvoiceError,
-  HasNoAdditionalLicenseWhenManuallyCollectedError,
 } = require('./Errors')
 } = require('./Errors')
 const EmailHelper = require('../Helpers/EmailHelper')
 const EmailHelper = require('../Helpers/EmailHelper')
 const { InvalidEmailError } = require('../Errors/Errors')
 const { InvalidEmailError } = require('../Errors/Errors')
@@ -124,22 +123,6 @@ async function ensureSubscriptionHasNoPastDueInvoice(subscription) {
   }
   }
 }
 }
 
 
-async function ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
-  paymentProviderSubscription
-) {
-  if (
-    paymentProviderSubscription.isCollectionMethodManual &&
-    !paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)
-  ) {
-    throw new HasNoAdditionalLicenseWhenManuallyCollectedError(
-      'This subscription is being collected manually has no "additional-license" add-on',
-      {
-        subscription_id: paymentProviderSubscription.id,
-      }
-    )
-  }
-}
-
 async function getUsersGroupSubscriptionDetails(userId) {
 async function getUsersGroupSubscriptionDetails(userId) {
   const subscription =
   const subscription =
     await SubscriptionLocator.promises.getUsersSubscription(userId)
     await SubscriptionLocator.promises.getUsersSubscription(userId)
@@ -487,10 +470,6 @@ module.exports = {
   ensureSubscriptionHasNoPastDueInvoice: callbackify(
   ensureSubscriptionHasNoPastDueInvoice: callbackify(
     ensureSubscriptionHasNoPastDueInvoice
     ensureSubscriptionHasNoPastDueInvoice
   ),
   ),
-  ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual:
-    callbackify(
-      ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual
-    ),
   getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
   getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
@@ -505,7 +484,6 @@ module.exports = {
     ensureSubscriptionCollectionMethodIsNotManual,
     ensureSubscriptionCollectionMethodIsNotManual,
     ensureSubscriptionHasNoPendingChanges,
     ensureSubscriptionHasNoPendingChanges,
     ensureSubscriptionHasNoPastDueInvoice,
     ensureSubscriptionHasNoPastDueInvoice,
-    ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual,
     getTotalConfirmedUsersInGroup,
     getTotalConfirmedUsersInGroup,
     isUserPartOfGroup,
     isUserPartOfGroup,
     getUsersGroupSubscriptionDetails,
     getUsersGroupSubscriptionDetails,

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

@@ -4,7 +4,6 @@
 
 
 const { callbackifyAll } = require('@overleaf/promise-utils')
 const { callbackifyAll } = require('@overleaf/promise-utils')
 const { Subscription } = require('../../models/Subscription')
 const { Subscription } = require('../../models/Subscription')
-const SubscriptionHelper = require('./SubscriptionHelper')
 const { DeletedSubscription } = require('../../models/DeletedSubscription')
 const { DeletedSubscription } = require('../../models/DeletedSubscription')
 const logger = require('@overleaf/logger')
 const logger = require('@overleaf/logger')
 const {
 const {
@@ -176,8 +175,7 @@ const SubscriptionLocator = {
 
 
     const hasActiveGroupSubscription = memberSubscriptions.some(
     const hasActiveGroupSubscription = memberSubscriptions.some(
       subscription =>
       subscription =>
-        subscription.groupPlan &&
-        SubscriptionHelper.getPaidSubscriptionState(subscription) === 'active'
+        subscription.recurlyStatus?.state === 'active' && subscription.groupPlan
     )
     )
     if (hasActiveGroupSubscription) {
     if (hasActiveGroupSubscription) {
       // Member of a group plan
       // Member of a group plan
@@ -189,8 +187,7 @@ const SubscriptionLocator = {
 
 
     if (personalSubscription) {
     if (personalSubscription) {
       const hasActivePersonalSubscription =
       const hasActivePersonalSubscription =
-        SubscriptionHelper.getPaidSubscriptionState(personalSubscription) ===
-        'active'
+        personalSubscription.recurlyStatus?.state === 'active'
       if (hasActivePersonalSubscription) {
       if (hasActivePersonalSubscription) {
         if (personalSubscription.groupPlan) {
         if (personalSubscription.groupPlan) {
           // Owner of a group plan
           // Owner of a group plan

+ 5 - 1
services/web/app/src/Features/UserMembership/UserMembershipHandler.js

@@ -79,7 +79,11 @@ async function getPopulatedListOfMembers(entity, attributes) {
     }
     }
   }
   }
 
 
-  const users = await UserMembershipViewModel.promises.buildAsync(userObjects)
+  const users = await Promise.all(
+    userObjects.map(userObject =>
+      UserMembershipViewModel.promises.buildAsync(userObject)
+    )
+  )
 
 
   for (const user of users) {
   for (const user of users) {
     if (
     if (

+ 19 - 48
services/web/app/src/Features/UserMembership/UserMembershipViewModel.js

@@ -23,59 +23,30 @@ const UserMembershipViewModel = {
     }
     }
   },
   },
 
 
-  buildAsync(userOrIdOrEmailArray, callback) {
+  buildAsync(userOrIdOrEmail, callback) {
     if (callback == null) {
     if (callback == null) {
       callback = function () {}
       callback = function () {}
     }
     }
+    if (!isObjectIdInstance(userOrIdOrEmail)) {
+      // userOrIdOrEmail is a user or an email and can be parsed by #build
+      return callback(null, UserMembershipViewModel.build(userOrIdOrEmail))
+    }
 
 
-    const userObjectIds = userOrIdOrEmailArray.filter(isObjectIdInstance)
-
-    return UserGetter.getUsers(
-      userObjectIds,
-      {
-        email: 1,
-        first_name: 1,
-        last_name: 1,
-        lastLoggedIn: 1,
-        lastActive: 1,
-        enrollment: 1,
-      },
-      function (error, users) {
-        const results = []
-
-        if (error != null) {
-          userOrIdOrEmailArray.forEach(item => {
-            if (isObjectIdInstance(item)) {
-              results.push(buildUserViewModelWithId(item.toString()))
-            } else {
-              // `item` is a user or an email and can be parsed by #build
-              results.push(UserMembershipViewModel.build(item))
-            }
-          })
-        } else {
-          const usersMap = new Map()
-          for (const user of users) {
-            usersMap.set(user._id.toString(), user)
-          }
-
-          userOrIdOrEmailArray.forEach(item => {
-            if (isObjectIdInstance(item)) {
-              const user = usersMap.get(item.toString())
-              if (user == null) {
-                results.push(buildUserViewModelWithId(item.toString()))
-              } else {
-                results.push(buildUserViewModel(user))
-              }
-            } else {
-              // `item` is a user or an email and can be parsed by #build
-              results.push(UserMembershipViewModel.build(item))
-            }
-          })
-        }
-
-        callback(null, results)
+    const userId = userOrIdOrEmail
+    const projection = {
+      email: 1,
+      first_name: 1,
+      last_name: 1,
+      lastLoggedIn: 1,
+      lastActive: 1,
+      enrollment: 1,
+    }
+    return UserGetter.getUser(userId, projection, function (error, user) {
+      if (error != null || user == null) {
+        return callback(null, buildUserViewModelWithId(userId.toString()))
       }
       }
-    )
+      return callback(null, buildUserViewModel(user))
+    })
   },
   },
 }
 }
 
 

+ 3 - 3
services/web/frontend/extracted-translations.json

@@ -872,7 +872,8 @@
   "is_email_affiliated": "",
   "is_email_affiliated": "",
   "issued_on": "",
   "issued_on": "",
   "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
   "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
-  "it_looks_like_your_account_is_billed_manually_purchasing_additional_license_or_upgrading_subscription": "",
+  "it_looks_like_your_account_is_billed_manually": "",
+  "it_looks_like_your_account_is_billed_manually_upgrading_subscription": "",
   "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "",
   "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "",
   "italics": "",
   "italics": "",
   "join_beta_program": "",
   "join_beta_program": "",
@@ -1025,7 +1026,6 @@
   "math_inline": "",
   "math_inline": "",
   "maximum_files_uploaded_together": "",
   "maximum_files_uploaded_together": "",
   "maybe_later": "",
   "maybe_later": "",
-  "members_added": "",
   "members_management": "",
   "members_management": "",
   "mendeley_dynamic_sync_description": "",
   "mendeley_dynamic_sync_description": "",
   "mendeley_groups_loading_error": "",
   "mendeley_groups_loading_error": "",
@@ -1146,10 +1146,10 @@
   "on_free_plan_upgrade_to_access_features": "",
   "on_free_plan_upgrade_to_access_features": "",
   "one_step_away_from_professional_features": "",
   "one_step_away_from_professional_features": "",
   "only_group_admin_or_managers_can_delete_your_account_1": "",
   "only_group_admin_or_managers_can_delete_your_account_1": "",
-  "only_group_admin_or_managers_can_delete_your_account_10": "",
   "only_group_admin_or_managers_can_delete_your_account_3": "",
   "only_group_admin_or_managers_can_delete_your_account_3": "",
   "only_group_admin_or_managers_can_delete_your_account_6": "",
   "only_group_admin_or_managers_can_delete_your_account_6": "",
   "only_group_admin_or_managers_can_delete_your_account_7": "",
   "only_group_admin_or_managers_can_delete_your_account_7": "",
+  "only_group_admin_or_managers_can_delete_your_account_8": "",
   "only_group_admin_or_managers_can_delete_your_account_9": "",
   "only_group_admin_or_managers_can_delete_your_account_9": "",
   "only_importer_can_refresh": "",
   "only_importer_can_refresh": "",
   "open_action_menu": "",
   "open_action_menu": "",

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

@@ -30,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
 export const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50
 export const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50
@@ -49,6 +50,9 @@ function AddSeats() {
   const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
   const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
   const [poNumberInputError, setPoNumberInputError] = useState<string>()
   const [poNumberInputError, setPoNumberInputError] = 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()
@@ -369,12 +373,13 @@ function AddSeats() {
                       <FormText type="error">{addSeatsInputError}</FormText>
                       <FormText type="error">{addSeatsInputError}</FormText>
                     )}
                     )}
                   </FormGroup>
                   </FormGroup>
-                  {isCollectionMethodManual && (
-                    <PoNumber
-                      error={poNumberInputError}
-                      validate={validatePoNumber}
-                    />
-                  )}
+                  {isFlexibleGroupLicensingForManuallyBilledSubscriptions &&
+                    isCollectionMethodManual && (
+                      <PoNumber
+                        error={poNumberInputError}
+                        validate={validatePoNumber}
+                      />
+                    )}
                 </div>
                 </div>
                 <CostSummarySection
                 <CostSummarySection
                   isLoadingCostSummary={isLoadingCostSummary}
                   isLoadingCostSummary={isLoadingCostSummary}

+ 0 - 9
services/web/frontend/js/features/group-management/components/group-members.tsx

@@ -13,7 +13,6 @@ import OLCard from '@/features/ui/components/ol/ol-card'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLFormControl from '@/features/ui/components/ol/ol-form-control'
 import OLFormControl from '@/features/ui/components/ol/ol-form-control'
 import OLFormText from '@/features/ui/components/ol/ol-form-text'
 import OLFormText from '@/features/ui/components/ol/ol-form-text'
-import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 
 export default function GroupMembers() {
 export default function GroupMembers() {
   const { isReady } = useWaitForI18n()
   const { isReady } = useWaitForI18n()
@@ -27,7 +26,6 @@ export default function GroupMembers() {
     removeMemberError,
     removeMemberError,
     inviteMemberLoading,
     inviteMemberLoading,
     inviteError,
     inviteError,
-    memberAdded,
     paths,
     paths,
   } = useGroupMembersContext()
   } = useGroupMembersContext()
   const [emailString, setEmailString] = useState<string>('')
   const [emailString, setEmailString] = useState<string>('')
@@ -142,13 +140,6 @@ export default function GroupMembers() {
                 data-testid="add-more-members-form"
                 data-testid="add-more-members-form"
               >
               >
                 <p className="small">{t('invite_more_members')}</p>
                 <p className="small">{t('invite_more_members')}</p>
-                {memberAdded && (
-                  <OLNotification
-                    content={t('members_added')}
-                    type="success"
-                    className="mt-2 mb-3"
-                  />
-                )}
                 <ErrorAlert error={inviteError} />
                 <ErrorAlert error={inviteError} />
                 <form onSubmit={onAddMembersSubmit}>
                 <form onSubmit={onAddMembersSubmit}>
                   <OLRow>
                   <OLRow>

+ 22 - 7
services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx

@@ -2,9 +2,14 @@ import { Trans, useTranslation } from 'react-i18next'
 import OLNotification from '@/features/ui/components/ol/ol-notification'
 import OLNotification from '@/features/ui/components/ol/ol-notification'
 import Card from '@/features/group-management/components/card'
 import Card from '@/features/group-management/components/card'
 import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
 import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 
 function ManuallyCollectedSubscription() {
 function ManuallyCollectedSubscription() {
   const { t } = useTranslation()
   const { t } = useTranslation()
+  const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag(
+    'flexible-group-licensing-for-manually-billed-subscriptions'
+  )
+
   const { isReady } = useWaitForI18n()
   const { isReady } = useWaitForI18n()
 
 
   if (!isReady) {
   if (!isReady) {
@@ -17,13 +22,23 @@ function ManuallyCollectedSubscription() {
         type="error"
         type="error"
         title={t('account_billed_manually')}
         title={t('account_billed_manually')}
         content={
         content={
-          <Trans
-            i18nKey="it_looks_like_your_account_is_billed_manually_purchasing_additional_license_or_upgrading_subscription"
-            components={[
-              // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
-              <a href="/contact" rel="noreferrer noopener" />,
-            ]}
-          />
+          isFlexibleGroupLicensingForManuallyBilledSubscriptions ? (
+            <Trans
+              i18nKey="it_looks_like_your_account_is_billed_manually_upgrading_subscription"
+              components={[
+                // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                <a href="/contact" rel="noreferrer noopener" />,
+              ]}
+            />
+          ) : (
+            <Trans
+              i18nKey="it_looks_like_your_account_is_billed_manually"
+              components={[
+                // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                <a href="/contact" rel="noreferrer noopener" />,
+              ]}
+            />
+          )
         }
         }
         className="m-0"
         className="m-0"
       />
       />

+ 3 - 0
services/web/frontend/js/features/group-management/components/members-table/managed-user-status.tsx

@@ -30,6 +30,9 @@ export default function ManagedUserStatus({ user }: ManagedUserStatusProps) {
     </span>
     </span>
   )
   )
 
 
+  if (user.isEntityAdmin) {
+    return <span className="security-state-group-admin" />
+  }
   if (user.invite) {
   if (user.invite) {
     return managedUserInvite
     return managedUserInvite
   }
   }

+ 8 - 42
services/web/frontend/js/features/group-management/components/members-table/members-list.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect, useMemo } from 'react'
+import { useState, useRef, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { User } from '../../../../../../types/group-management/user'
 import { User } from '../../../../../../types/group-management/user'
 import { useGroupMembersContext } from '../../context/group-members-context'
 import { useGroupMembersContext } from '../../context/group-members-context'
@@ -13,9 +13,6 @@ import getMeta from '@/utils/meta'
 import UnlinkUserModal from './unlink-user-modal'
 import UnlinkUserModal from './unlink-user-modal'
 import OLTable from '@/features/ui/components/ol/ol-table'
 import OLTable from '@/features/ui/components/ol/ol-table'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
-import Pagination from '@/shared/components/pagination'
-
-const USERS_DISPLAY_LIMIT = 50
 
 
 type ManagedUsersListProps = {
 type ManagedUsersListProps = {
   groupId: string
   groupId: string
@@ -34,30 +31,13 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
   const managedUsersActive = getMeta('ol-managedUsersActive')
   const managedUsersActive = getMeta('ol-managedUsersActive')
   const groupSSOActive = getMeta('ol-groupSSOActive')
   const groupSSOActive = getMeta('ol-groupSSOActive')
   const tHeadRowRef = useRef<HTMLTableRowElement>(null)
   const tHeadRowRef = useRef<HTMLTableRowElement>(null)
-  const [pagination, setPagination] = useState({ currPage: 1, totalPages: 1 })
-
-  const usersForCurrentPage = useMemo(
-    () =>
-      users.slice(
-        (pagination.currPage - 1) * USERS_DISPLAY_LIMIT,
-        pagination.currPage * USERS_DISPLAY_LIMIT
-      ),
-    [users, pagination.currPage]
-  )
-
-  const handlePageClick = (
-    _e: React.MouseEvent<HTMLButtonElement>,
-    page: number
-  ) => {
-    setPagination(p => ({ ...p, currPage: page }))
-  }
+  const [colSpan, setColSpan] = useState(0)
 
 
   useEffect(() => {
   useEffect(() => {
-    setPagination(p => ({
-      ...p,
-      totalPages: Math.ceil(users.length / USERS_DISPLAY_LIMIT),
-    }))
-  }, [users.length])
+    if (tHeadRowRef.current) {
+      setColSpan(tHeadRowRef.current.querySelectorAll('th').length)
+    }
+  }, [])
 
 
   return (
   return (
     <div>
     <div>
@@ -113,17 +93,12 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
         <tbody>
         <tbody>
           {users.length === 0 && (
           {users.length === 0 && (
             <tr>
             <tr>
-              <td
-                className="text-center"
-                colSpan={
-                  tHeadRowRef.current?.querySelectorAll('th').length ?? 0
-                }
-              >
+              <td className="text-center" colSpan={colSpan}>
                 <small>{t('no_members')}</small>
                 <small>{t('no_members')}</small>
               </td>
               </td>
             </tr>
             </tr>
           )}
           )}
-          {usersForCurrentPage.map(user => (
+          {users.map(user => (
             <MemberRow
             <MemberRow
               key={user.email}
               key={user.email}
               user={user}
               user={user}
@@ -136,15 +111,6 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
           ))}
           ))}
         </tbody>
         </tbody>
       </OLTable>
       </OLTable>
-      {pagination.totalPages > 1 && (
-        <div className="d-flex justify-content-center">
-          <Pagination
-            handlePageClick={handlePageClick}
-            currentPage={pagination.currPage}
-            totalPages={pagination.totalPages}
-          />
-        </div>
-      )}
       {userToOffboard && (
       {userToOffboard && (
         <OffboardManagedUserModal
         <OffboardManagedUserModal
           user={userToOffboard}
           user={userToOffboard}

+ 1 - 14
services/web/frontend/js/features/group-management/context/group-members-context.tsx

@@ -31,7 +31,6 @@ export type GroupMembersContextValue = {
   updateMemberView: (userId: string, updatedUser: User) => void
   updateMemberView: (userId: string, updatedUser: User) => void
   inviteMemberLoading: boolean
   inviteMemberLoading: boolean
   inviteError?: APIError
   inviteError?: APIError
-  memberAdded: boolean
   paths: { [key: string]: string }
   paths: { [key: string]: string }
 }
 }
 
 
@@ -59,7 +58,6 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
   const [inviteError, setInviteError] = useState<APIError>()
   const [inviteError, setInviteError] = useState<APIError>()
   const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
   const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
   const [removeMemberError, setRemoveMemberError] = useState<APIError>()
   const [removeMemberError, setRemoveMemberError] = useState<APIError>()
-  const [memberAdded, setMemberAdded] = useState(false)
 
 
   const groupId = getMeta('ol-groupId')
   const groupId = getMeta('ol-groupId')
 
 
@@ -76,9 +74,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
   const addMembers = useCallback(
   const addMembers = useCallback(
     (emailString: string) => {
     (emailString: string) => {
       setInviteError(undefined)
       setInviteError(undefined)
-      setMemberAdded(false)
       const emails = parseEmails(emailString)
       const emails = parseEmails(emailString)
-      let isError = false
       mapSeries(emails, async email => {
       mapSeries(emails, async email => {
         setInviteUserInflightCount(count => count + 1)
         setInviteUserInflightCount(count => count + 1)
         try {
         try {
@@ -98,15 +94,8 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
         } catch (error: unknown) {
         } catch (error: unknown) {
           debugConsole.error(error)
           debugConsole.error(error)
           setInviteError((error as FetchError)?.data?.error || {})
           setInviteError((error as FetchError)?.data?.error || {})
-          isError = true
         }
         }
-        setInviteUserInflightCount(count => {
-          const newCount = count - 1
-          if (newCount === 0 && !isError) {
-            setMemberAdded(true)
-          }
-          return newCount
-        })
+        setInviteUserInflightCount(count => count - 1)
       })
       })
     },
     },
     [paths.addMember, users, setUsers]
     [paths.addMember, users, setUsers]
@@ -184,7 +173,6 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
       removeMemberError,
       removeMemberError,
       inviteMemberLoading: inviteUserInflightCount > 0,
       inviteMemberLoading: inviteUserInflightCount > 0,
       inviteError,
       inviteError,
-      memberAdded,
       paths,
       paths,
     }),
     }),
     [
     [
@@ -203,7 +191,6 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
       removeMemberError,
       removeMemberError,
       inviteUserInflightCount,
       inviteUserInflightCount,
       inviteError,
       inviteError,
-      memberAdded,
       paths,
       paths,
     ]
     ]
   )
   )

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

@@ -247,7 +247,6 @@ export interface Meta {
   'ol-splitTestVariants': { [name: string]: string }
   'ol-splitTestVariants': { [name: string]: string }
   'ol-ssoDisabled': boolean
   'ol-ssoDisabled': boolean
   'ol-ssoErrorMessage': string
   'ol-ssoErrorMessage': string
-  'ol-stripeAccountId': string
   'ol-stripeCustomerId': string
   'ol-stripeCustomerId': string
   'ol-subscription': any // TODO: mixed types, split into two fields
   'ol-subscription': any // TODO: mixed types, split into two fields
   'ol-subscriptionChangePreview': SubscriptionChangePreview
   'ol-subscriptionChangePreview': SubscriptionChangePreview

+ 3 - 3
services/web/locales/en.json

@@ -1130,7 +1130,8 @@
   "issued_on": "Issued: __date__",
   "issued_on": "Issued: __date__",
   "it": "Italian",
   "it": "Italian",
   "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch</0> with our Support team for more help.",
   "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch</0> with our Support team for more help.",
-  "it_looks_like_your_account_is_billed_manually_purchasing_additional_license_or_upgrading_subscription": "It looks like your account is being billed manually - purchasing additional licenses or upgrading your subscription can only be done by the Support team. Please <0>get in touch</0> for help.",
+  "it_looks_like_your_account_is_billed_manually": "It looks like your account is being billed manually - adding seats or upgrading your subscription can only be done by the Support team. Please <0>get in touch</0> for help.",
+  "it_looks_like_your_account_is_billed_manually_upgrading_subscription": "It looks like your account is being billed manually - upgrading your subscription can only be done by the Support team. Please <0>get in touch</0> for help.",
   "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "It looks like your payment details are missing. Please <0>update your billing information</0>, or <1>get in touch</1> with our Support team for more help.",
   "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "It looks like your payment details are missing. Please <0>update your billing information</0>, or <1>get in touch</1> with our Support team for more help.",
   "italics": "Italics",
   "italics": "Italics",
   "ja": "Japanese",
   "ja": "Japanese",
@@ -1341,7 +1342,6 @@
   "may": "May",
   "may": "May",
   "maybe_later": "Maybe later",
   "maybe_later": "Maybe later",
   "member_picker": "Select number of users for group plan",
   "member_picker": "Select number of users for group plan",
-  "members_added": "Member(s) added.",
   "members_management": "Members management",
   "members_management": "Members management",
   "mendeley": "Mendeley",
   "mendeley": "Mendeley",
   "mendeley_dynamic_sync_description": "With the Mendeley integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Mendeley library directly from __appName__.",
   "mendeley_dynamic_sync_description": "With the Mendeley integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Mendeley library directly from __appName__.",
@@ -1504,10 +1504,10 @@
   "ongoing_experiments": "Ongoing experiments",
   "ongoing_experiments": "Ongoing experiments",
   "online_latex_editor": "Online LaTeX Editor",
   "online_latex_editor": "Online LaTeX Editor",
   "only_group_admin_or_managers_can_delete_your_account_1": "By becoming a managed user, your organization will have admin rights over your account and control over your stuff, including the right to close your account and access, delete and share your stuff. As a result:",
   "only_group_admin_or_managers_can_delete_your_account_1": "By becoming a managed user, your organization will have admin rights over your account and control over your stuff, including the right to close your account and access, delete and share your stuff. As a result:",
-  "only_group_admin_or_managers_can_delete_your_account_10": "If you have an individual subscription, we’ll automatically terminate it and cancel its renewal when your account becomes managed. To request a pro-rata refund for the remainder, please contact Support.",
   "only_group_admin_or_managers_can_delete_your_account_3": "Your group admin and group managers will be able to reassign ownership of your projects to another group member.",
   "only_group_admin_or_managers_can_delete_your_account_3": "Your group admin and group managers will be able to reassign ownership of your projects to another group member.",
   "only_group_admin_or_managers_can_delete_your_account_6": "Only your group admin or group managers will be able to delete your account or change your account back into an unmanaged account.",
   "only_group_admin_or_managers_can_delete_your_account_6": "Only your group admin or group managers will be able to delete your account or change your account back into an unmanaged account.",
   "only_group_admin_or_managers_can_delete_your_account_7": "Only your group admin or group managers will be able to delete your account or change your account into an unmanaged account.",
   "only_group_admin_or_managers_can_delete_your_account_7": "Only your group admin or group managers will be able to delete your account or change your account into an unmanaged account.",
+  "only_group_admin_or_managers_can_delete_your_account_8": "We’ll cancel the renewal of your subscription, reach out to Support to request a pro-rata refund. Your individual subscription will be terminated when your account becomes managed.",
   "only_group_admin_or_managers_can_delete_your_account_9": "Once you have become a managed user, <0>you yourself cannot change it back to an unmanaged account</0>. <1>Learn more about managed Overleaf accounts.</1>",
   "only_group_admin_or_managers_can_delete_your_account_9": "Once you have become a managed user, <0>you yourself cannot change it back to an unmanaged account</0>. <1>Learn more about managed Overleaf accounts.</1>",
   "only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
   "only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
   "open_action_menu": "Open __name__ action menu",
   "open_action_menu": "Open __name__ action menu",

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

@@ -14,6 +14,9 @@ describe('<AddSeats />', function () {
       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-isCollectionMethodManual', true)
+      win.metaAttributesCache.set('ol-splitTestVariants', {
+        'flexible-group-licensing-for-manually-billed-subscriptions': 'enabled',
+      })
     })
     })
 
 
     cy.mount(
     cy.mount(

+ 22 - 0
services/web/test/frontend/features/group-management/components/members-table/managed-user-status.spec.tsx

@@ -61,4 +61,26 @@ describe('MemberStatus', function () {
       cy.get('.security-state-not-managed').contains('Managed')
       cy.get('.security-state-not-managed').contains('Managed')
     })
     })
   })
   })
+
+  describe('with the group admin', function () {
+    const user: User = {
+      _id: 'some-user',
+      email: 'some.user@example.com',
+      first_name: 'Some',
+      last_name: 'User',
+      invite: false,
+      last_active_at: new Date(),
+      enrollment: undefined,
+      isEntityAdmin: true,
+    }
+    beforeEach(function () {
+      cy.mount(<ManagedUserStatus user={user} />)
+    })
+
+    it('should render no state indicator', function () {
+      cy.get('.security-state-group-admin')
+        .contains('Managed')
+        .should('not.exist')
+    })
+  })
 })
 })

+ 0 - 10
services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx

@@ -121,16 +121,6 @@ describe('MembersList', function () {
         users[1].last_name
         users[1].last_name
       )
       )
     })
     })
-    it('should render the pagination navigation', function () {
-      cy.window().then(win => {
-        win.metaAttributesCache.set(
-          'ol-users',
-          Array.from({ length: 50 }).flatMap(() => users.flat())
-        )
-      })
-      mountManagedUsersList()
-      cy.findByRole('navigation', { name: /pagination navigation/i })
-    })
   })
   })
 
 
   describe('empty user list', function () {
   describe('empty user list', function () {

+ 13 - 28
services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs

@@ -73,8 +73,6 @@ describe('SubscriptionGroupController', function () {
           .resolves(ctx.previewSubscriptionChangeData),
           .resolves(ctx.previewSubscriptionChangeData),
         checkBillingInfoExistence: sinon.stub().resolves(ctx.paymentMethod),
         checkBillingInfoExistence: sinon.stub().resolves(ctx.paymentMethod),
         updateSubscriptionPaymentTerms: sinon.stub().resolves(),
         updateSubscriptionPaymentTerms: sinon.stub().resolves(),
-        ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual:
-          sinon.stub().resolves(),
       },
       },
     }
     }
 
 
@@ -107,6 +105,12 @@ describe('SubscriptionGroupController', function () {
       },
       },
     }
     }
 
 
+    ctx.SplitTestHandler = {
+      promises: {
+        getAssignment: sinon.stub().resolves({ variant: 'enabled' }),
+      },
+    }
+
     ctx.UserGetter = {
     ctx.UserGetter = {
       promises: {
       promises: {
         getUserEmail: sinon.stub().resolves(ctx.user.email),
         getUserEmail: sinon.stub().resolves(ctx.user.email),
@@ -136,7 +140,6 @@ describe('SubscriptionGroupController', function () {
       InactiveError: class extends Error {},
       InactiveError: class extends Error {},
       SubtotalLimitExceededError: class extends Error {},
       SubtotalLimitExceededError: class extends Error {},
       HasPastDueInvoiceError: class extends Error {},
       HasPastDueInvoiceError: class extends Error {},
-      HasNoAdditionalLicenseWhenManuallyCollectedError: class extends Error {},
     }
     }
 
 
     vi.doMock(
     vi.doMock(
@@ -168,6 +171,13 @@ describe('SubscriptionGroupController', function () {
       default: ctx.Modules,
       default: ctx.Modules,
     }))
     }))
 
 
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
     vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
     vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
       default: ctx.UserGetter,
       default: ctx.UserGetter,
     }))
     }))
@@ -446,9 +456,6 @@ describe('SubscriptionGroupController', function () {
             ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence
             ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence
               .calledWith(ctx.recurlySubscription, ctx.adminUserId)
               .calledWith(ctx.recurlySubscription, ctx.adminUserId)
               .should.equal(true)
               .should.equal(true)
-            ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual
-              .calledWith(ctx.recurlySubscription)
-              .should.equal(true)
             page.should.equal('subscriptions/add-seats')
             page.should.equal('subscriptions/add-seats')
             props.subscriptionId.should.equal(ctx.subscriptionId)
             props.subscriptionId.should.equal(ctx.subscriptionId)
             props.groupName.should.equal(ctx.subscription.teamName)
             props.groupName.should.equal(ctx.subscription.teamName)
@@ -514,28 +521,6 @@ describe('SubscriptionGroupController', function () {
       })
       })
     })
     })
 
 
-    it('should redirect to manually collected subscription error page when collection method is manual and has no additional license add-on', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual =
-          sinon
-            .stub()
-            .throws(
-              new ctx.Errors.HasNoAdditionalLicenseWhenManuallyCollectedError()
-            )
-
-        const res = {
-          redirect: url => {
-            url.should.equal(
-              '/user/subscription/group/manually-collected-subscription'
-            )
-            resolve()
-          },
-        }
-
-        ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
-      })
-    })
-
     it('should redirect to subscription page when there is a pending change', async function (ctx) {
     it('should redirect to subscription page when there is a pending change', async function (ctx) {
       await new Promise(resolve => {
       await new Promise(resolve => {
         ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
         ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =

+ 0 - 43
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -806,49 +806,6 @@ describe('SubscriptionGroupHandler', function () {
     })
     })
   })
   })
 
 
-  describe('ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual', function () {
-    it('should throw if the subscription is manually collected and has no additional license add-on', async function () {
-      await expect(
-        this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
-          {
-            isCollectionMethodManual: true,
-            hasAddOn: sinon
-              .stub()
-              .withArgs('additional-license')
-              .returns(false),
-          }
-        )
-      ).to.be.rejectedWith(
-        'This subscription is being collected manually has no "additional-license" add-on'
-      )
-    })
-
-    it('should not throw if the subscription is not manually collected and has no additional license add-on and ', async function () {
-      await expect(
-        this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
-          {
-            isCollectionMethodManual: false,
-            hasAddOn: sinon
-              .stub()
-              .withArgs('additional-license')
-              .returns(false),
-          }
-        )
-      ).to.not.be.rejected
-    })
-
-    it('should not throw if the subscription is not manually collected and has additional license add-on', async function () {
-      await expect(
-        this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
-          {
-            isCollectionMethodManual: true,
-            hasAddOn: sinon.stub().withArgs('additional-license').returns(true),
-          }
-        )
-      ).to.not.be.rejected
-    })
-  })
-
   describe('getGroupPlanUpgradePreview', function () {
   describe('getGroupPlanUpgradePreview', function () {
     it('should generate preview for subscription upgrade', async function () {
     it('should generate preview for subscription upgrade', async function () {
       const result = await this.Handler.promises.getGroupPlanUpgradePreview(
       const result = await this.Handler.promises.getGroupPlanUpgradePreview(

+ 15 - 13
services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js

@@ -48,7 +48,7 @@ describe('UserMembershipHandler', function () {
 
 
     this.UserMembershipViewModel = {
     this.UserMembershipViewModel = {
       promises: {
       promises: {
-        buildAsync: sinon.stub().resolves([{ _id: 'mock-member-id' }]),
+        buildAsync: sinon.stub().resolves({ _id: 'mock-member-id' }),
       },
       },
       build: sinon.stub().returns(this.newUser),
       build: sinon.stub().returns(this.newUser),
     }
     }
@@ -118,26 +118,26 @@ describe('UserMembershipHandler', function () {
           this.subscription,
           this.subscription,
           EntityConfigs.group
           EntityConfigs.group
         )
         )
+        const expectedCallcount =
+          this.subscription.member_ids.length +
+          this.subscription.invited_emails.length +
+          this.subscription.teamInvites.length
         expect(
         expect(
-          this.UserMembershipViewModel.promises.buildAsync
-        ).to.be.calledOnceWith(
-          this.subscription.invited_emails.concat(
-            this.subscription.teamInvites[0].email,
-            this.subscription.member_ids
-          )
-        )
+          this.UserMembershipViewModel.promises.buildAsync.callCount
+        ).to.equal(expectedCallcount)
       })
       })
     })
     })
 
 
-    describe('group managers', function () {
+    describe('group mamagers', function () {
       it('build view model for all managers', async function () {
       it('build view model for all managers', async function () {
         await this.UserMembershipHandler.promises.getUsers(
         await this.UserMembershipHandler.promises.getUsers(
           this.subscription,
           this.subscription,
           EntityConfigs.groupManagers
           EntityConfigs.groupManagers
         )
         )
+        const expectedCallcount = this.subscription.manager_ids.length
         expect(
         expect(
-          this.UserMembershipViewModel.promises.buildAsync
-        ).to.be.calledOnceWith(this.subscription.manager_ids)
+          this.UserMembershipViewModel.promises.buildAsync.callCount
+        ).to.equal(expectedCallcount)
       })
       })
     })
     })
 
 
@@ -147,9 +147,11 @@ describe('UserMembershipHandler', function () {
           this.institution,
           this.institution,
           EntityConfigs.institution
           EntityConfigs.institution
         )
         )
+
+        const expectedCallcount = this.institution.managerIds.length
         expect(
         expect(
-          this.UserMembershipViewModel.promises.buildAsync
-        ).to.be.calledOnceWith(this.institution.managerIds)
+          this.UserMembershipViewModel.promises.buildAsync.callCount
+        ).to.equal(expectedCallcount)
       })
       })
     })
     })
   })
   })

+ 15 - 21
services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js

@@ -25,7 +25,7 @@ const {
 
 
 describe('UserMembershipViewModel', function () {
 describe('UserMembershipViewModel', function () {
   beforeEach(function () {
   beforeEach(function () {
-    this.UserGetter = { getUsers: sinon.stub() }
+    this.UserGetter = { getUser: sinon.stub() }
     this.UserMembershipViewModel = SandboxedModule.require(modulePath, {
     this.UserMembershipViewModel = SandboxedModule.require(modulePath, {
       requires: {
       requires: {
         'mongodb-legacy': { ObjectId },
         'mongodb-legacy': { ObjectId },
@@ -87,10 +87,9 @@ describe('UserMembershipViewModel', function () {
     })
     })
 
 
     it('build email', function (done) {
     it('build email', function (done) {
-      this.UserGetter.getUsers.yields(null, [])
       return this.UserMembershipViewModel.buildAsync(
       return this.UserMembershipViewModel.buildAsync(
-        [this.email],
-        (error, [viewModel]) => {
+        this.email,
+        (error, viewModel) => {
           assertCalledWith(this.UserMembershipViewModel.build, this.email)
           assertCalledWith(this.UserMembershipViewModel.build, this.email)
           return done()
           return done()
         }
         }
@@ -98,10 +97,9 @@ describe('UserMembershipViewModel', function () {
     })
     })
 
 
     it('build user', function (done) {
     it('build user', function (done) {
-      this.UserGetter.getUsers.yields(null, [])
       return this.UserMembershipViewModel.buildAsync(
       return this.UserMembershipViewModel.buildAsync(
-        [this.user],
-        (error, [viewModel]) => {
+        this.user,
+        (error, viewModel) => {
           assertCalledWith(this.UserMembershipViewModel.build, this.user)
           assertCalledWith(this.UserMembershipViewModel.build, this.user)
           return done()
           return done()
         }
         }
@@ -109,34 +107,30 @@ describe('UserMembershipViewModel', function () {
     })
     })
 
 
     it('build user id', function (done) {
     it('build user id', function (done) {
-      const user = {
-        ...this.user,
-        _id: new ObjectId(),
-      }
-      this.UserGetter.getUsers.yields(null, [user])
+      this.UserGetter.getUser.yields(null, this.user)
       return this.UserMembershipViewModel.buildAsync(
       return this.UserMembershipViewModel.buildAsync(
-        [user._id],
-        (error, [viewModel]) => {
+        new ObjectId(),
+        (error, viewModel) => {
           expect(error).not.to.exist
           expect(error).not.to.exist
           assertNotCalled(this.UserMembershipViewModel.build)
           assertNotCalled(this.UserMembershipViewModel.build)
-          expect(viewModel._id.toString()).to.equal(user._id.toString())
-          expect(viewModel.email).to.equal(user.email)
-          expect(viewModel.first_name).to.equal(user.first_name)
+          expect(viewModel._id).to.equal(this.user._id)
+          expect(viewModel.email).to.equal(this.user.email)
+          expect(viewModel.first_name).to.equal(this.user.first_name)
           expect(viewModel.invite).to.equal(false)
           expect(viewModel.invite).to.equal(false)
           expect(viewModel.email).to.exist
           expect(viewModel.email).to.exist
           expect(viewModel.enrollment).to.exist
           expect(viewModel.enrollment).to.exist
-          expect(viewModel.enrollment).to.deep.equal(user.enrollment)
+          expect(viewModel.enrollment).to.deep.equal(this.user.enrollment)
           return done()
           return done()
         }
         }
       )
       )
     })
     })
 
 
     it('build user id with error', function (done) {
     it('build user id with error', function (done) {
-      this.UserGetter.getUsers.yields(new Error('nope'), [])
+      this.UserGetter.getUser.yields(new Error('nope'))
       const userId = new ObjectId()
       const userId = new ObjectId()
       return this.UserMembershipViewModel.buildAsync(
       return this.UserMembershipViewModel.buildAsync(
-        [userId],
-        (error, [viewModel]) => {
+        userId,
+        (error, viewModel) => {
           expect(error).not.to.exist
           expect(error).not.to.exist
           assertNotCalled(this.UserMembershipViewModel.build)
           assertNotCalled(this.UserMembershipViewModel.build)
           expect(viewModel._id).to.equal(userId.toString())
           expect(viewModel._id).to.equal(userId.toString())