From 57114c45039915f5c24b88322d4e37e2f4c89c74 Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Thu, 16 Jun 2022 16:29:49 +0200 Subject: [PATCH] [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 --- .../src/Features/Project/ProjectController.js | 21 +- .../Subscription/SubscriptionUpdater.js | 5 + .../SubscriptionViewModelBuilder.js | 95 +++- services/web/app/src/models/Subscription.js | 11 + .../project/list/_current_plan_mixins.pug | 91 ++++ .../app/views/project/list/project-list.pug | 18 +- .../stylesheets/app/project-list.less | 10 + services/web/locales/en.json | 11 +- .../src/Project/ProjectControllerTests.js | 47 +- .../SubscriptionViewModelBuilderTests.js | 413 ++++++++++++++++++ 10 files changed, 665 insertions(+), 57 deletions(-) create mode 100644 services/web/app/views/project/list/_current_plan_mixins.pug create mode 100644 services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index c24a7a2a97..efe691bab2 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/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 = diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index 0a243c9898..65d9bf7417 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/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) { diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index dfc91aaee8..6683d74f00 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/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, }, } diff --git a/services/web/app/src/models/Subscription.js b/services/web/app/src/models/Subscription.js index f8f6459484..a557d61873 100644 --- a/services/web/app/src/models/Subscription.js +++ b/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 diff --git a/services/web/app/views/project/list/_current_plan_mixins.pug b/services/web/app/views/project/list/_current_plan_mixins.pug new file mode 100644 index 0000000000..f1f99cb831 --- /dev/null +++ b/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 diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index 74f86f4653..f8ed41c481 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/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( diff --git a/services/web/frontend/stylesheets/app/project-list.less b/services/web/frontend/stylesheets/app/project-list.less index c9c5c46ee0..2c796e412f 100644 --- a/services/web/frontend/stylesheets/app/project-list.less +++ b/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; + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 97e8dfdcf2..ca4a3e100f 100644 --- a/services/web/locales/en.json +++ b/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. 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 free plan", + "free_plan_tooltip": "Click to find out how you could benefit from Overleaf premium features!", + "premium_plan_label": "You’re using Overleaf Premium", + "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 Overleaf Premium trial", + "trial_remaining_days": "__days__ more days on your Overleaf Premium trial" } diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 0fcf34e855..b45c3b8b5f 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/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' diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js new file mode 100644 index 0000000000..94d1441bec --- /dev/null +++ b/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, + }) + }) + }) +})