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

[web] Display the current plan in the project list dashboard (#8293)

* Display the current plan in the project list dashboard

* Add unit tests for SubscriptionViewModelBuilder#getBestSubscription

* Handle free trial for group subscriptions

* Reuse the info-badge icon for the plan labels

* Do not display subscription status when projects are selected

* Custom tooltip for group subscriptions with team name

GitOrigin-RevId: 40982f70cf9fb7c92058e417b73c84af1648c33e
Alexandre Bourdin 4 лет назад
Родитель
Сommit
57114c4503

+ 14 - 7
services/web/app/src/Features/Project/ProjectController.js

@@ -41,6 +41,7 @@ const SpellingHandler = require('../Spelling/SpellingHandler')
 const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
 const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
 const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures')
+const SubscriptionViewModelBuilder = require('../Subscription/SubscriptionViewModelBuilder')
 
 const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
   if (!affiliation.institution) return false
@@ -440,7 +441,18 @@ const ProjectController = {
             )
           })
         },
-
+        usersBestSubscription(cb) {
+          SubscriptionViewModelBuilder.getBestSubscription(
+            { _id: userId },
+            (err, subscription) => {
+              if (err) {
+                // do not fail loading the project list when fetching the best subscription fails
+                return cb(null, { type: 'error' })
+              }
+              cb(null, subscription)
+            }
+          )
+        },
         primaryEmailCheckActive(cb) {
           SplitTestHandler.getAssignment(
             req,
@@ -595,11 +607,6 @@ const ProjectController = {
           )
         }
 
-        // Persistent upgrade prompts
-        const showToolbarUpgradePrompt =
-          !results.hasSubscription &&
-          !userEmails.some(e => e.emailHasInstitutionLicence)
-
         ProjectController._injectProjectUsers(projects, (error, projects) => {
           if (error != null) {
             return next(error)
@@ -622,7 +629,7 @@ const ProjectController = {
             isOverleaf: !!Settings.overleaf,
             metadata: { viewport: false },
             showThinFooter: true, // don't show the fat footer on the projects dashboard, as there's a fixed space available
-            showToolbarUpgradePrompt,
+            usersBestSubscription: results.usersBestSubscription,
           }
 
           const paidUser =

+ 5 - 0
services/web/app/src/Features/Subscription/SubscriptionUpdater.js

@@ -249,6 +249,11 @@ async function updateSubscriptionFromRecurly(
 
   subscription.recurlySubscription_id = recurlySubscription.uuid
   subscription.planCode = updatedPlanCode
+  subscription.recurly = {
+    state: recurlySubscription.state,
+    trialStartedAt: recurlySubscription.trial_started_at,
+    trialEndsAt: recurlySubscription.trial_ends_at,
+  }
 
   if (plan.groupPlan) {
     if (!subscription.groupPlan) {

+ 94 - 1
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

@@ -3,6 +3,7 @@ const RecurlyWrapper = require('./RecurlyWrapper')
 const PlansLocator = require('./PlansLocator')
 const SubscriptionFormatters = require('./SubscriptionFormatters')
 const SubscriptionLocator = require('./SubscriptionLocator')
+const SubscriptionUpdater = require('./SubscriptionUpdater')
 const V1SubscriptionManager = require('./V1SubscriptionManager')
 const InstitutionsGetter = require('../Institutions/InstitutionsGetter')
 const PublishersGetter = require('../Publishers/PublishersGetter')
@@ -10,12 +11,13 @@ const sanitizeHtml = require('sanitize-html')
 const _ = require('underscore')
 const async = require('async')
 const SubscriptionHelper = require('./SubscriptionHelper')
-const { promisify } = require('../../util/promises')
+const { callbackify, promisify } = require('../../util/promises')
 const {
   InvalidError,
   NotFoundError,
   V1ConnectionError,
 } = require('../Errors/Errors')
+const FeaturesHelper = require('./FeaturesHelper')
 
 function buildHostedLink(type) {
   return `/user/subscription/recurly/${type}`
@@ -314,6 +316,78 @@ function buildUsersSubscriptionViewModel(user, callback) {
   )
 }
 
+async function getBestSubscription(user) {
+  let [
+    individualSubscription,
+    memberGroupSubscriptions,
+    currentInstitutionsWithLicence,
+  ] = await Promise.all([
+    SubscriptionLocator.promises.getUsersSubscription(user),
+    SubscriptionLocator.promises.getMemberSubscriptions(user),
+    InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(user._id),
+  ])
+  if (individualSubscription && !individualSubscription.recurly?.state) {
+    const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
+      individualSubscription.recurlySubscription_id,
+      { includeAccount: true }
+    )
+    await SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
+      recurlySubscription,
+      individualSubscription
+    )
+    individualSubscription =
+      await SubscriptionLocator.promises.getUsersSubscription(user)
+  }
+  let bestSubscription = {
+    type: 'free',
+  }
+  if (currentInstitutionsWithLicence?.length) {
+    for (const institutionMembership of currentInstitutionsWithLicence) {
+      const plan = PlansLocator.findLocalPlanInSettings(
+        Settings.institutionPlanCode
+      )
+      if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
+        bestSubscription = {
+          type: 'commons',
+          subscription: institutionMembership,
+          plan,
+        }
+      }
+    }
+  }
+  if (memberGroupSubscriptions?.length) {
+    for (const groupSubscription of memberGroupSubscriptions) {
+      const plan = PlansLocator.findLocalPlanInSettings(
+        groupSubscription.planCode
+      )
+      if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
+        const remainingTrialDays = _getRemainingTrialDays(groupSubscription)
+        bestSubscription = {
+          type: 'group',
+          subscription: groupSubscription,
+          plan,
+          remainingTrialDays,
+        }
+      }
+    }
+  }
+  if (individualSubscription && !individualSubscription.groupPlan) {
+    const plan = PlansLocator.findLocalPlanInSettings(
+      individualSubscription.planCode
+    )
+    if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
+      const remainingTrialDays = _getRemainingTrialDays(individualSubscription)
+      bestSubscription = {
+        type: 'individual',
+        subscription: individualSubscription,
+        plan,
+        remainingTrialDays,
+      }
+    }
+  }
+  return bestSubscription
+}
+
 function buildPlansList(currentPlan) {
   const { plans } = Settings
 
@@ -368,11 +442,30 @@ function buildPlansList(currentPlan) {
   return result
 }
 
+function _isPlanEqualOrBetter(planA, planB) {
+  return FeaturesHelper.isFeatureSetBetter(
+    planA?.features || {},
+    planB?.features || {}
+  )
+}
+
+function _getRemainingTrialDays(subscription) {
+  const now = new Date()
+  const trialEndDate = subscription.recurly?.trialEndsAt
+  return trialEndDate && trialEndDate > now
+    ? Math.ceil(
+        (trialEndDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
+      )
+    : -1
+}
+
 module.exports = {
   buildUsersSubscriptionViewModel,
   buildPlansList,
+  getBestSubscription: callbackify(getBestSubscription),
   promises: {
     buildUsersSubscriptionViewModel: promisify(buildUsersSubscriptionViewModel),
     getRedirectToHostedPage,
+    getBestSubscription,
   },
 }

+ 11 - 0
services/web/app/src/models/Subscription.js

@@ -38,6 +38,17 @@ const SubscriptionSchema = new Schema({
       },
     },
   },
+  recurly: {
+    state: {
+      type: String,
+    },
+    trialStartedAt: {
+      type: Date,
+    },
+    trialEndsAt: {
+      type: Date,
+    },
+  },
 })
 
 // Subscriptions have no v1 data to fetch

+ 91 - 0
services/web/app/views/project/list/_current_plan_mixins.pug

@@ -0,0 +1,91 @@
+mixin current_plan()
+	if (usersBestSubscription)
+		.text-right.pull-right.current-plan
+			case usersBestSubscription.type
+				when 'free'
+					+free_plan()
+				when 'individual'
+					if (usersBestSubscription.remainingTrialDays >= 0)
+						+individual_plan_trial(usersBestSubscription.subscription, usersBestSubscription.plan, usersBestSubscription.remainingTrialDays)
+					else
+						+individual_plan_active(usersBestSubscription.subscription, usersBestSubscription.plan)
+				when 'group'
+					if (usersBestSubscription.remainingTrialDays >= 0)
+						+group_plan_trial(usersBestSubscription.subscription, usersBestSubscription.plan, usersBestSubscription.remainingTrialDays)
+					else
+						+group_plan_active(usersBestSubscription.subscription, usersBestSubscription.plan)
+				when 'commons'
+					+commons_plan(usersBestSubscription.subscription, usersBestSubscription.plan)
+
+mixin individual_plan_trial(subscription, plan, remainingTrialDays)
+	a.current-plan-label(
+		tooltip=translate('plan_tooltip', { plan: plan.name }),
+		tooltip-placement="bottom"
+		href="/learn/how-to/Overleaf_premium_features"
+	)
+		if (remainingTrialDays === 1)
+			| !{translate('trial_last_day')} 
+			span.info-badge
+		else
+			| !{translate('trial_remaining_days', { days: remainingTrialDays })} 
+			span.info-badge
+
+mixin individual_plan_active(subscription, plan)
+	a.current-plan-label(
+		tooltip=translate('plan_tooltip', {plan: plan.name}),
+		tooltip-placement="bottom"
+		href="/learn/how-to/Overleaf_premium_features"
+	)
+		| !{translate('premium_plan_label')} 
+		span.info-badge
+
+mixin group_plan_trial(subscription, plan, remainingTrialDays)
+	a.current-plan-label(
+		tooltip=translate(subscription.teamName != null ? 'group_plan_with_name_tooltip' : 'group_plan_tooltip', { plan: plan.name, groupName: subscription.teamName }),
+		tooltip-placement="bottom"
+		href="/learn/how-to/Overleaf_premium_features"
+	)
+		if (remainingTrialDays === 1)
+			| !{translate('trial_last_day')} 
+			span.info-badge
+		else
+			| !{translate('trial_remaining_days', { days: remainingTrialDays })} 
+			span.info-badge
+
+mixin group_plan_active(subscription, plan)
+	a.current-plan-label(
+		tooltip=translate(subscription.teamName != null ? 'group_plan_with_name_tooltip' : 'group_plan_tooltip', { plan: plan.name, groupName: subscription.teamName }),
+		tooltip-placement="bottom"
+		href="/learn/how-to/Overleaf_premium_features"
+	)
+		| !{translate('premium_plan_label')} 
+		span.info-badge
+		
+mixin commons_plan(subscription, plan)
+	a.current-plan-label(
+		tooltip=translate('commons_plan_tooltip', { plan: plan.name, institution: subscription.name }),
+		tooltip-placement="bottom"
+		href="/learn/how-to/Overleaf_premium_features"
+	)
+		| !{translate('premium_plan_label')} 
+		span.info-badge
+
+mixin free_plan()
+	a.current-plan-label(
+		tooltip=translate('free_plan_tooltip'),
+		tooltip-placement="bottom"
+		href="/learn/how-to/Overleaf_premium_features"
+	)
+		| !{translate('free_plan_label')} 
+		span.info-badge
+		|  
+	a.btn.btn-primary(
+		href="/user/subscription/plans"
+		event-tracking="upgrade-button-click"
+		event-tracking-mb="true"
+		event-tracking-ga="subscription-funnel"
+		event-tracking-action="dashboard-top"
+		event-tracking-label="upgrade"
+		event-tracking-trigger="click"
+		event-segmentation='{"source": "dashboard-top"}'
+	) Upgrade

+ 4 - 14
services/web/app/views/project/list/project-list.pug

@@ -1,3 +1,5 @@
+include ./_current_plan_mixins
+
 .row
 	.col-xs-12(ng-cloak)
 		form.project-search.form-horizontal(role="form")
@@ -23,21 +25,9 @@
 					ng-show="searchText.value.length > 0"
 				) #{translate('clear_search')}
 
-
 		.project-tools(ng-cloak)
-			if (showToolbarUpgradePrompt)
-				.project-list-upgrade-prompt(ng-cloak ng-hide="selectedProjects.length > 0")
-					span You're on the free plan  
-					a.btn.btn-primary(
-						href="/user/subscription/plans"
-						event-tracking="upgrade-button-click"
-						event-tracking-mb="true"
-						event-tracking-ga="subscription-funnel"
-						event-tracking-action="dashboard-top"
-						event-tracking-label="upgrade"
-						event-tracking-trigger="click"
-						event-segmentation='{"source": "dashboard-top"}'
-					) Upgrade
+			.project-list-upgrade-prompt(ng-cloak ng-hide="selectedProjects.length > 0")
+				+current_plan()
 			.btn-toolbar
 				.btn-group(ng-hide="selectedProjects.length < 1")
 					a.btn.btn-default(

+ 10 - 0
services/web/frontend/stylesheets/app/project-list.less

@@ -81,6 +81,7 @@
 .project-tools {
   display: inline;
   float: right;
+  line-height: @line-height-base;
 }
 
 .tags-dropdown-menu {
@@ -563,3 +564,12 @@ ul.project-list {
     margin-left: -100px;
   }
 }
+
+.current-plan {
+  vertical-align: middle;
+  line-height: @line-height-base;
+  a.current-plan-label {
+    text-decoration: none;
+    color: @text-color;
+  }
+}

+ 10 - 1
services/web/locales/en.json

@@ -1765,5 +1765,14 @@
   "try_to_compile_despite_errors": "Try to compile despite errors",
   "stop_on_first_error_enabled_title": "No PDF: Stop on first error enabled",
   "stop_on_first_error_enabled_description": "<0>“Stop on first error” is enabled.</0> Disabling it may allow the compiler to produce a PDF (but your project will still have errors).",
-  "disable_stop_on_first_error": "Disable “Stop on first error”"
+  "disable_stop_on_first_error": "Disable “Stop on first error”",
+  "free_plan_label": "You’re on the <b>free plan</b>",
+  "free_plan_tooltip": "Click to find out how you could benefit from Overleaf premium features!",
+  "premium_plan_label": "You’re using <b>Overleaf Premium</b>",
+  "plan_tooltip": "You’re on the __plan__ plan. Click to find out how you could benefit from Overleaf premium features!",
+  "group_plan_tooltip": "You are on the __plan__ plan as a member of a group subscription. Click to find out how you could benefit from Overleaf premium features!",
+  "group_plan_with_name_tooltip": "You are on the __plan__ plan as a member of a group subscription, __groupName__. Click to find out how you could benefit from Overleaf premium features!",
+  "commons_plan_tooltip": "You’re on the __plan__ plan because of your affiliation with __institution__. Click to find out how you could benefit from Overleaf premium features!",
+  "trial_last_day": "This is the last day of your <b>Overleaf Premium</b> trial",
+  "trial_remaining_days": "__days__ more days on your <b>Overleaf Premium</b> trial"
 }

+ 13 - 34
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -137,6 +137,9 @@ describe('ProjectController', function () {
     this.InstitutionsFeatures = {
       hasLicence: sinon.stub().callsArgWith(1, null, false),
     }
+    this.SubscriptionViewModelBuilder = {
+      getBestSubscription: sinon.stub().yields(null, { type: 'free' }),
+    }
 
     this.ProjectController = SandboxedModule.require(MODULE_PATH, {
       requires: {
@@ -173,6 +176,8 @@ describe('ProjectController', function () {
         '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
         '../../models/Project': {},
         '../Analytics/AnalyticsManager': { recordEventForUser: () => {} },
+        '../Subscription/SubscriptionViewModelBuilder':
+          this.SubscriptionViewModelBuilder,
         '../../infrastructure/Modules': {
           hooks: { fire: sinon.stub().yields(null, []) },
         },
@@ -515,6 +520,14 @@ describe('ProjectController', function () {
       this.ProjectController.projectListPage(this.req, this.res)
     })
 
+    it("should send the user's best subscription", function (done) {
+      this.res.render = (pageName, opts) => {
+        expect(opts.usersBestSubscription).to.deep.include({ type: 'free' })
+        done()
+      }
+      this.ProjectController.projectListPage(this.req, this.res)
+    })
+
     describe('front widget', function (done) {
       beforeEach(function () {
         this.settings.overleaf = {
@@ -555,40 +568,6 @@ describe('ProjectController', function () {
       })
     })
 
-    describe('persistent upgrade prompt', function () {
-      it('should show for a user without a subscription or only non-paid affiliations', function (done) {
-        this.res.render = (pageName, opts) => {
-          expect(opts.showToolbarUpgradePrompt).to.equal(true)
-          done()
-        }
-        this.ProjectController.projectListPage(this.req, this.res)
-      })
-      it('should not show for a user with a subscription', function (done) {
-        this.LimitationsManager.hasPaidSubscription = sinon
-          .stub()
-          .callsArgWith(1, null, true)
-        this.res.render = (pageName, opts) => {
-          expect(opts.showToolbarUpgradePrompt).to.equal(false)
-          done()
-        }
-        this.ProjectController.projectListPage(this.req, this.res)
-      })
-      it('should not show for a user with an affiliated paid university', function (done) {
-        const emailWithProAffiliation = {
-          email: 'pro@example.com',
-          emailHasInstitutionLicence: true,
-        }
-        this.UserGetter.getUserFullEmails = sinon
-          .stub()
-          .yields(null, [emailWithProAffiliation])
-        this.res.render = (pageName, opts) => {
-          expect(opts.showToolbarUpgradePrompt).to.equal(false)
-          done()
-        }
-        this.ProjectController.projectListPage(this.req, this.res)
-      })
-    })
-
     describe('With Institution SSO feature', function () {
       beforeEach(function (done) {
         this.institutionEmail = 'test@overleaf.com'

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

@@ -0,0 +1,413 @@
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+const { assert } = require('chai')
+const modulePath =
+  '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder'
+
+describe('SubscriptionViewModelBuilder', function () {
+  beforeEach(function () {
+    this.user = { _id: '5208dd34438842e2db333333' }
+    this.recurlySubscription_id = '123abc456def'
+    this.planCode = 'collaborator_monthly'
+    this.planFeatures = {
+      compileGroup: 'priority',
+      collaborators: -1,
+      compileTimeout: 240,
+    }
+    this.plan = {
+      planCode: this.planCode,
+      features: this.planFeatures,
+    }
+    this.individualSubscription = {
+      planCode: this.planCode,
+      plan: this.plan,
+      recurlySubscription_id: this.recurlySubscription_id,
+      recurly: {
+        state: 'active',
+      },
+    }
+
+    this.groupPlanCode = 'group_collaborator_monthly'
+    this.groupPlanFeatures = {
+      compileGroup: 'priority',
+      collaborators: 10,
+      compileTimeout: 240,
+    }
+    this.groupPlan = {
+      planCode: this.groupPlanCode,
+      features: this.groupPlanFeatures,
+    }
+    this.groupSubscription = {
+      planCode: this.groupPlanCode,
+      plan: this.plan,
+      recurly: {
+        state: 'active',
+      },
+    }
+
+    this.commonsPlanCode = 'commons_license'
+    this.commonsPlanFeatures = {
+      compileGroup: 'priority',
+      collaborators: '-1',
+      compileTimeout: 240,
+    }
+    this.commonsPlan = {
+      planCode: this.commonsPlanCode,
+      features: this.commonsPlanFeatures,
+    }
+    this.commonsSubscription = {
+      planCode: this.commonsPlanCode,
+      plan: this.commonsPlan,
+      name: 'Digital Science',
+    }
+
+    this.Settings = {
+      institutionPlanCode: this.commonsPlanCode,
+    }
+    this.SubscriptionLocator = {
+      promises: {
+        getUsersSubscription: sinon.stub().resolves(),
+        getMemberSubscriptions: sinon.stub().resolves(),
+      },
+      findLocalPlanInSettings: sinon.stub(),
+    }
+    this.InstitutionsGetter = {
+      promises: {
+        getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
+      },
+    }
+    this.RecurlyWrapper = {
+      promises: {
+        getSubscription: sinon.stub().resolves(),
+      },
+    }
+    this.SubscriptionUpdater = {
+      promises: {
+        updateSubscriptionFromRecurly: sinon.stub().resolves(),
+      },
+    }
+    this.PlansLocator = {
+      findLocalPlanInSettings: sinon.stub(),
+    }
+    this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, {
+      requires: {
+        '@overleaf/settings': this.Settings,
+        './SubscriptionLocator': this.SubscriptionLocator,
+        '../Institutions/InstitutionsGetter': this.InstitutionsGetter,
+        './RecurlyWrapper': this.RecurlyWrapper,
+        './SubscriptionUpdater': this.SubscriptionUpdater,
+        './PlansLocator': this.PlansLocator,
+        './V1SubscriptionManager': {},
+        './SubscriptionFormatters': {},
+        '../Publishers/PublishersGetter': {},
+        './SubscriptionHelper': {},
+      },
+    })
+
+    this.PlansLocator.findLocalPlanInSettings
+      .withArgs(this.planCode)
+      .returns(this.plan)
+      .withArgs(this.groupPlanCode)
+      .returns(this.groupPlan)
+      .withArgs(this.commonsPlanCode)
+      .returns(this.commonsPlan)
+  })
+
+  describe('getBestSubscription', function () {
+    it('should return a free plan when user has no subscription or affiliation', async function () {
+      const usersBestSubscription =
+        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+          this.user
+        )
+      assert.deepEqual(usersBestSubscription, { type: 'free' })
+    })
+
+    describe('with a individual subscription only', function () {
+      it('should return a individual subscription when user has an active one', async function () {
+        this.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(this.user)
+          .resolves(this.individualSubscription)
+
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: this.individualSubscription,
+          plan: this.plan,
+          remainingTrialDays: -1,
+        })
+      })
+
+      it('should return a individual subscription with remaining free trial days', async function () {
+        const threeDaysLater = new Date()
+        threeDaysLater.setDate(threeDaysLater.getDate() + 3)
+        this.individualSubscription.recurly.trialEndsAt = threeDaysLater
+        this.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(this.user)
+          .resolves(this.individualSubscription)
+
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: this.individualSubscription,
+          plan: this.plan,
+          remainingTrialDays: 3,
+        })
+      })
+
+      it('should return a individual subscription with free trial on last day', async function () {
+        const threeHoursLater = new Date()
+        threeHoursLater.setTime(threeHoursLater.getTime() + 3 * 60 * 60 * 1000)
+        this.individualSubscription.recurly.trialEndsAt = threeHoursLater
+        this.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(this.user)
+          .resolves(this.individualSubscription)
+
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: this.individualSubscription,
+          plan: this.plan,
+          remainingTrialDays: 1,
+        })
+      })
+
+      it('should update subscription if recurly data is missing', async function () {
+        this.individualSubscriptionWithoutRecurly = {
+          planCode: this.planCode,
+          plan: this.plan,
+          recurlySubscription_id: this.recurlySubscription_id,
+        }
+        this.recurlySubscription = {
+          state: 'active',
+        }
+        this.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(this.user)
+          .onCall(0)
+          .resolves(this.individualSubscriptionWithoutRecurly)
+          .withArgs(this.user)
+          .onCall(1)
+          .resolves(this.individualSubscription)
+        this.RecurlyWrapper.promises.getSubscription
+          .withArgs(this.individualSubscription.recurlySubscription_id, {
+            includeAccount: true,
+          })
+          .resolves(this.recurlySubscription)
+
+        const usersBestSubscription =
+          await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+            this.user
+          )
+
+        sinon.assert.calledWith(
+          this.RecurlyWrapper.promises.getSubscription,
+          this.individualSubscriptionWithoutRecurly.recurlySubscription_id,
+          { includeAccount: true }
+        )
+        sinon.assert.calledWith(
+          this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly,
+          this.recurlySubscription,
+          this.individualSubscriptionWithoutRecurly
+        )
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: this.individualSubscription,
+          plan: this.plan,
+          remainingTrialDays: -1,
+        })
+      })
+    })
+
+    it('should return a group subscription when user has one', async function () {
+      this.SubscriptionLocator.promises.getMemberSubscriptions
+        .withArgs(this.user)
+        .resolves([this.groupSubscription])
+      const usersBestSubscription =
+        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+          this.user
+        )
+      assert.deepEqual(usersBestSubscription, {
+        type: 'group',
+        subscription: this.groupSubscription,
+        plan: this.groupPlan,
+        remainingTrialDays: -1,
+      })
+    })
+
+    it('should return a commons subscription when user has an institution affiliation', async function () {
+      this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
+        .withArgs(this.user._id)
+        .resolves([this.commonsSubscription])
+
+      const usersBestSubscription =
+        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+          this.user
+        )
+
+      assert.deepEqual(usersBestSubscription, {
+        type: 'commons',
+        subscription: this.commonsSubscription,
+        plan: this.commonsPlan,
+      })
+    })
+  })
+
+  describe('with multiple subscriptions', function () {
+    beforeEach(function () {
+      this.SubscriptionLocator.promises.getUsersSubscription
+        .withArgs(this.user)
+        .resolves(this.individualSubscription)
+      this.SubscriptionLocator.promises.getMemberSubscriptions
+        .withArgs(this.user)
+        .resolves([this.groupSubscription])
+      this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
+        .withArgs(this.user._id)
+        .resolves([this.commonsSubscription])
+    })
+
+    it('should return individual when the individual subscription has the best feature set', async function () {
+      this.commonsPlan.features = {
+        compileGroup: 'standard',
+        collaborators: 1,
+        compileTimeout: 60,
+      }
+
+      const usersBestSubscription =
+        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+          this.user
+        )
+
+      assert.deepEqual(usersBestSubscription, {
+        type: 'individual',
+        subscription: this.individualSubscription,
+        plan: this.plan,
+        remainingTrialDays: -1,
+      })
+    })
+
+    it('should return group when the group subscription has the best feature set', async function () {
+      this.plan.features = {
+        compileGroup: 'standard',
+        collaborators: 1,
+        compileTimeout: 60,
+      }
+      this.commonsPlan.features = {
+        compileGroup: 'standard',
+        collaborators: 1,
+        compileTimeout: 60,
+      }
+
+      const usersBestSubscription =
+        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+          this.user
+        )
+
+      assert.deepEqual(usersBestSubscription, {
+        type: 'group',
+        subscription: this.groupSubscription,
+        plan: this.groupPlan,
+        remainingTrialDays: -1,
+      })
+    })
+
+    it('should return commons when the commons affiliation has the best feature set', async function () {
+      this.plan.features = {
+        compileGroup: 'priority',
+        collaborators: 5,
+        compileTimeout: 240,
+      }
+      this.groupPlan.features = {
+        compileGroup: 'standard',
+        collaborators: 1,
+        compileTimeout: 60,
+      }
+      this.commonsPlan.features = {
+        compileGroup: 'priority',
+        collaborators: -1,
+        compileTimeout: 240,
+      }
+
+      const usersBestSubscription =
+        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+          this.user
+        )
+
+      assert.deepEqual(usersBestSubscription, {
+        type: 'commons',
+        subscription: this.commonsSubscription,
+        plan: this.commonsPlan,
+      })
+    })
+
+    it('should return individual with equal feature sets', async function () {
+      this.plan.features = {
+        compileGroup: 'priority',
+        collaborators: -1,
+        compileTimeout: 240,
+      }
+      this.groupPlan.features = {
+        compileGroup: 'priority',
+        collaborators: -1,
+        compileTimeout: 240,
+      }
+      this.commonsPlan.features = {
+        compileGroup: 'priority',
+        collaborators: -1,
+        compileTimeout: 240,
+      }
+
+      const usersBestSubscription =
+        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+          this.user
+        )
+
+      assert.deepEqual(usersBestSubscription, {
+        type: 'individual',
+        subscription: this.individualSubscription,
+        plan: this.plan,
+        remainingTrialDays: -1,
+      })
+    })
+
+    it('should return group over commons with equal feature sets', async function () {
+      this.plan.features = {
+        compileGroup: 'standard',
+        collaborators: 1,
+        compileTimeout: 60,
+      }
+      this.groupPlan.features = {
+        compileGroup: 'priority',
+        collaborators: -1,
+        compileTimeout: 240,
+      }
+      this.commonsPlan.features = {
+        compileGroup: 'priority',
+        collaborators: -1,
+        compileTimeout: 240,
+      }
+
+      const usersBestSubscription =
+        await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
+          this.user
+        )
+
+      assert.deepEqual(usersBestSubscription, {
+        type: 'group',
+        subscription: this.groupSubscription,
+        plan: this.groupPlan,
+        remainingTrialDays: -1,
+      })
+    })
+  })
+})