* preserve pending changes when generating change requests * re-apply pending term_end changes after immediate updates * block changes when Stripe subscription has multiple phases * handle MultiplePendingChangesError & rm PendingChangeError GitOrigin-RevId: 0af11044766ff48e683d684ad6d62b732d17290c
1248 lines
39 KiB
JavaScript
1248 lines
39 KiB
JavaScript
import { vi, expect } from 'vitest'
|
||
import mongodb from 'mongodb-legacy'
|
||
import sinon from 'sinon'
|
||
import MockRequest from '../helpers/MockRequest.mjs'
|
||
import { InvalidEmailError } from '../../../../app/src/Features/Errors/Errors.js'
|
||
|
||
const { ObjectId } = mongodb
|
||
|
||
const modulePath =
|
||
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler.mjs'
|
||
|
||
describe('SubscriptionGroupHandler', function () {
|
||
beforeEach(async function (ctx) {
|
||
ctx.adminUser_id = '12321'
|
||
ctx.newEmail = 'bob@smith.com'
|
||
ctx.user_id = '3121321'
|
||
ctx.email = 'jim@example.com'
|
||
ctx.user = { _id: ctx.user_id, email: ctx.newEmail }
|
||
ctx.subscription_id = '31DSd1123D'
|
||
ctx.adding = 1
|
||
ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
|
||
ctx.PaymentProviderEntities = {
|
||
MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
|
||
}
|
||
ctx.localPlanInSettings = {
|
||
membersLimit: 5,
|
||
membersLimitAddOn: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
}
|
||
|
||
ctx.subscription = {
|
||
admin_id: ctx.adminUser_id,
|
||
manager_ids: [ctx.adminUser_id],
|
||
_id: ctx.subscription_id,
|
||
membersLimit: 100,
|
||
}
|
||
|
||
ctx.changeRequest = {
|
||
timeframe: 'now',
|
||
subscription: {
|
||
id: 'test_id',
|
||
},
|
||
}
|
||
|
||
ctx.termsAndConditionsUpdate = {
|
||
termsAndConditions: 'T&C copy',
|
||
}
|
||
|
||
ctx.poNumberAndTermsAndConditionsUpdate = {
|
||
poNumber: '4444',
|
||
...ctx.termsAndConditionsUpdate,
|
||
}
|
||
|
||
ctx.recurlySubscription = {
|
||
id: 123,
|
||
addOns: [
|
||
{
|
||
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
quantity: 1,
|
||
},
|
||
],
|
||
getRequestForAddOnUpdate: sinon.stub().returns(ctx.changeRequest),
|
||
getRequestForGroupPlanUpgrade: sinon.stub().returns(ctx.changeRequest),
|
||
getRequestForAddOnPurchase: sinon.stub().returns(ctx.changeRequest),
|
||
getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
|
||
.stub()
|
||
.returns(ctx.changeRequest),
|
||
getRequestForPoNumberAndTermsAndConditionsUpdate: sinon
|
||
.stub()
|
||
.returns(ctx.poNumberAndTermsAndConditionsUpdate),
|
||
getRequestForTermsAndConditionsUpdate: sinon
|
||
.stub()
|
||
.returns(ctx.termsAndConditionsUpdate),
|
||
currency: 'USD',
|
||
hasAddOn(code) {
|
||
return this.addOns.some(addOn => addOn.code === code)
|
||
},
|
||
get isCollectionMethodManual() {
|
||
return false
|
||
},
|
||
}
|
||
|
||
ctx.SubscriptionLocator = {
|
||
promises: {
|
||
getUsersSubscription: sinon.stub().resolves({
|
||
groupPlan: true,
|
||
recurlyStatus: {
|
||
state: 'active',
|
||
},
|
||
}),
|
||
getSubscriptionByMemberIdAndId: sinon.stub(),
|
||
getSubscription: sinon.stub().resolves(ctx.subscription),
|
||
},
|
||
}
|
||
|
||
ctx.changePreview = {
|
||
currency: 'USD',
|
||
}
|
||
|
||
ctx.SubscriptionController = {
|
||
makeChangePreview: sinon.stub().resolves(ctx.changePreview),
|
||
getPlanNameForDisplay: sinon.stub().resolves(),
|
||
}
|
||
|
||
ctx.SubscriptionUpdater = {
|
||
promises: {
|
||
removeUserFromGroup: sinon.stub().resolves(),
|
||
getSubscription: sinon.stub().resolves(),
|
||
},
|
||
}
|
||
|
||
ctx.Subscription = {
|
||
updateOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
|
||
updateMany: sinon.stub().returns({ exec: sinon.stub().resolves }),
|
||
findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
|
||
}
|
||
|
||
ctx.User = {
|
||
find: sinon.stub().returns({ exec: sinon.stub().resolves }),
|
||
}
|
||
|
||
ctx.SessionManager = {
|
||
getLoggedInUserId: sinon.stub().returns(ctx.user._id),
|
||
}
|
||
|
||
ctx.previewSubscriptionChange = {
|
||
nextAddOns: [
|
||
{
|
||
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
quantity: ctx.recurlySubscription.addOns[0].quantity + ctx.adding,
|
||
},
|
||
],
|
||
subscription: {
|
||
planName: 'test plan',
|
||
},
|
||
}
|
||
|
||
ctx.applySubscriptionChange = {}
|
||
|
||
ctx.RecurlyClient = {
|
||
promises: {
|
||
getSubscription: sinon.stub().resolves(ctx.recurlySubscription),
|
||
getPaymentMethod: sinon.stub().resolves(ctx.paymentMethod),
|
||
previewSubscriptionChange: sinon
|
||
.stub()
|
||
.resolves(ctx.previewSubscriptionChange),
|
||
applySubscriptionChangeRequest: sinon
|
||
.stub()
|
||
.resolves(ctx.applySubscriptionChange),
|
||
updateSubscriptionDetails: sinon.stub().resolves(),
|
||
},
|
||
}
|
||
|
||
ctx.PlansLocator = {
|
||
findLocalPlanInSettings: sinon.stub().returns(ctx.localPlanInSettings),
|
||
}
|
||
|
||
ctx.SubscriptionHandler = {
|
||
promises: {
|
||
syncSubscription: sinon.stub().resolves(),
|
||
},
|
||
}
|
||
|
||
ctx.TeamInvitesHandler = {
|
||
promises: {
|
||
revokeInvite: sinon.stub().resolves(),
|
||
createInvite: sinon.stub().resolves(),
|
||
},
|
||
}
|
||
|
||
ctx.GroupPlansData = {
|
||
enterprise: {
|
||
collaborator: {
|
||
USD: {
|
||
5: {
|
||
price_in_cents: 10000,
|
||
additional_license_legacy_price_in_cents: 5000,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
educational: {
|
||
collaborator: {
|
||
USD: {
|
||
5: {
|
||
price_in_cents: 10000,
|
||
additional_license_legacy_price_in_cents: 5000,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
ctx.Modules = {
|
||
promises: {
|
||
hooks: {
|
||
fire: sinon.stub(),
|
||
},
|
||
},
|
||
}
|
||
|
||
ctx.Modules.promises.hooks.fire
|
||
.withArgs('generateTermsAndConditions')
|
||
.resolves(['T&Cs'])
|
||
.withArgs('getPaymentFromRecord')
|
||
.resolves([
|
||
{
|
||
subscription: ctx.recurlySubscription,
|
||
account: { hasPastDueInvoice: false },
|
||
},
|
||
])
|
||
.withArgs('previewSubscriptionChangeRequest')
|
||
.resolves([ctx.previewSubscriptionChange])
|
||
.withArgs('previewGroupPlanUpgrade')
|
||
.resolves([{ subscriptionChange: ctx.previewSubscriptionChange }])
|
||
|
||
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
|
||
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
|
||
)
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Subscription/SubscriptionUpdater.mjs',
|
||
() => ({
|
||
default: ctx.SubscriptionUpdater,
|
||
})
|
||
)
|
||
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Subscription/SubscriptionLocator',
|
||
() => ({
|
||
default: ctx.SubscriptionLocator,
|
||
})
|
||
)
|
||
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Subscription/SubscriptionController',
|
||
() => ({
|
||
default: ctx.SubscriptionController,
|
||
})
|
||
)
|
||
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Subscription/SubscriptionHandler',
|
||
() => ({
|
||
default: ctx.SubscriptionHandler,
|
||
})
|
||
)
|
||
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Subscription/TeamInvitesHandler',
|
||
() => ({
|
||
default: ctx.TeamInvitesHandler,
|
||
})
|
||
)
|
||
|
||
vi.doMock('../../../../app/src/models/Subscription', () => ({
|
||
Subscription: ctx.Subscription,
|
||
}))
|
||
|
||
vi.doMock('../../../../app/src/models/User', () => ({
|
||
User: ctx.User,
|
||
}))
|
||
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Subscription/RecurlyClient',
|
||
() => ({
|
||
default: ctx.RecurlyClient,
|
||
})
|
||
)
|
||
|
||
vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
|
||
default: ctx.PlansLocator,
|
||
}))
|
||
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Subscription/PaymentProviderEntities',
|
||
() => ({
|
||
default: ctx.PaymentProviderEntities,
|
||
})
|
||
)
|
||
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Authentication/SessionManager',
|
||
() => ({
|
||
default: ctx.SessionManager,
|
||
})
|
||
)
|
||
|
||
vi.doMock(
|
||
'../../../../app/src/Features/Subscription/GroupPlansData',
|
||
() => ({
|
||
default: ctx.GroupPlansData,
|
||
})
|
||
)
|
||
|
||
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
|
||
default: ctx.Modules,
|
||
}))
|
||
|
||
ctx.Handler = (await import(modulePath)).default
|
||
})
|
||
|
||
describe('removeUserFromGroup', function () {
|
||
it('should call the subscription updater to remove the user', async function (ctx) {
|
||
const auditLog = { ipAddress: '0:0:0:0', initiatorId: ctx.user._id }
|
||
await ctx.Handler.promises.removeUserFromGroup(
|
||
ctx.adminUser_id,
|
||
ctx.user._id,
|
||
auditLog
|
||
)
|
||
|
||
ctx.SubscriptionUpdater.promises.removeUserFromGroup
|
||
.calledWith(ctx.adminUser_id, ctx.user._id, auditLog)
|
||
.should.equal(true)
|
||
})
|
||
})
|
||
|
||
describe('replaceUserReferencesInGroups', function () {
|
||
beforeEach(async function (ctx) {
|
||
ctx.oldId = 'ba5eba11'
|
||
ctx.newId = '5ca1ab1e'
|
||
await ctx.Handler.promises.replaceUserReferencesInGroups(
|
||
ctx.oldId,
|
||
ctx.newId
|
||
)
|
||
})
|
||
|
||
it('replaces the admin_id', function (ctx) {
|
||
ctx.Subscription.updateOne
|
||
.calledWith({ admin_id: ctx.oldId }, { admin_id: ctx.newId })
|
||
.should.equal(true)
|
||
})
|
||
|
||
it('replaces the manager_ids', function (ctx) {
|
||
ctx.Subscription.updateMany
|
||
.calledWith(
|
||
{ manager_ids: 'ba5eba11' },
|
||
{ $addToSet: { manager_ids: '5ca1ab1e' } }
|
||
)
|
||
.should.equal(true)
|
||
|
||
ctx.Subscription.updateMany
|
||
.calledWith(
|
||
{ manager_ids: 'ba5eba11' },
|
||
{ $pull: { manager_ids: 'ba5eba11' } }
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
|
||
it('replaces the member ids', function (ctx) {
|
||
ctx.Subscription.updateMany
|
||
.calledWith(
|
||
{ member_ids: ctx.oldId },
|
||
{ $addToSet: { member_ids: ctx.newId } }
|
||
)
|
||
.should.equal(true)
|
||
|
||
ctx.Subscription.updateMany
|
||
.calledWith(
|
||
{ member_ids: ctx.oldId },
|
||
{ $pull: { member_ids: ctx.oldId } }
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
})
|
||
|
||
describe('isUserPartOfGroup', function () {
|
||
beforeEach(function (ctx) {
|
||
ctx.subscription_id = '123ed13123'
|
||
})
|
||
|
||
it('should return true when user is part of subscription', async function (ctx) {
|
||
ctx.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves({
|
||
_id: ctx.subscription_id,
|
||
})
|
||
const partOfGroup = await ctx.Handler.promises.isUserPartOfGroup(
|
||
ctx.user_id,
|
||
ctx.subscription_id
|
||
)
|
||
partOfGroup.should.equal(true)
|
||
})
|
||
|
||
it('should return false when no subscription is found', async function (ctx) {
|
||
ctx.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves(
|
||
null
|
||
)
|
||
const partOfGroup = await ctx.Handler.promises.isUserPartOfGroup(
|
||
ctx.user_id,
|
||
ctx.subscription_id
|
||
)
|
||
partOfGroup.should.equal(false)
|
||
})
|
||
})
|
||
|
||
describe('getTotalConfirmedUsersInGroup', function () {
|
||
describe('for existing subscriptions', function () {
|
||
beforeEach(function (ctx) {
|
||
ctx.subscription.member_ids = ['12321', '3121321']
|
||
})
|
||
it('should call the subscription locator and return 2 users', async function (ctx) {
|
||
const count = await ctx.Handler.promises.getTotalConfirmedUsersInGroup(
|
||
ctx.subscription_id
|
||
)
|
||
ctx.SubscriptionLocator.promises.getSubscription
|
||
.calledWith(ctx.subscription_id)
|
||
.should.equal(true)
|
||
count.should.equal(2)
|
||
})
|
||
})
|
||
describe('for nonexistent subscriptions', function () {
|
||
it('should return undefined', async function (ctx) {
|
||
const count =
|
||
await ctx.Handler.promises.getTotalConfirmedUsersInGroup('fake-id')
|
||
expect(count).not.to.exist
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('getUsersGroupSubscriptionDetails', function () {
|
||
beforeEach(function (ctx) {
|
||
ctx.req = new MockRequest(vi)
|
||
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
|
||
...ctx.localPlanInSettings,
|
||
canUseFlexibleLicensing: true,
|
||
})
|
||
})
|
||
|
||
it('should throw if the subscription is not a group plan', async function (ctx) {
|
||
ctx.SubscriptionLocator.promises.getUsersSubscription = sinon
|
||
.stub()
|
||
.resolves({ groupPlan: false })
|
||
|
||
await expect(
|
||
ctx.Handler.promises.getUsersGroupSubscriptionDetails(ctx.adminUser_id)
|
||
).to.be.rejectedWith('User subscription is not a group plan')
|
||
})
|
||
|
||
it('should return users group subscription details', async function (ctx) {
|
||
const data = await ctx.Handler.promises.getUsersGroupSubscriptionDetails(
|
||
ctx.adminUser_id
|
||
)
|
||
|
||
expect(data).to.deep.equal({
|
||
userId: ctx.adminUser_id,
|
||
subscription: {
|
||
groupPlan: true,
|
||
recurlyStatus: {
|
||
state: 'active',
|
||
},
|
||
},
|
||
plan: {
|
||
membersLimit: 5,
|
||
membersLimitAddOn:
|
||
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
canUseFlexibleLicensing: true,
|
||
},
|
||
paymentProviderSubscription: ctx.recurlySubscription,
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('add seats subscription change', function () {
|
||
beforeEach(function (ctx) {
|
||
ctx.req = new MockRequest(vi)
|
||
Object.assign(ctx.req.body, { adding: ctx.adding })
|
||
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
|
||
...ctx.localPlanInSettings,
|
||
canUseFlexibleLicensing: true,
|
||
})
|
||
})
|
||
|
||
describe('has "additional-license" add-on', function () {
|
||
beforeEach(function (ctx) {
|
||
ctx.recurlySubscription.addOns = [
|
||
{
|
||
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
quantity: 6,
|
||
},
|
||
]
|
||
ctx.prevQuantity = ctx.recurlySubscription.addOns[0].quantity
|
||
ctx.previewSubscriptionChange.nextAddOns = [
|
||
{
|
||
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
quantity: ctx.prevQuantity + ctx.adding,
|
||
},
|
||
]
|
||
})
|
||
|
||
afterEach(function (ctx) {
|
||
sinon.assert.notCalled(
|
||
ctx.recurlySubscription.getRequestForAddOnPurchase
|
||
)
|
||
|
||
ctx.recurlySubscription.getRequestForAddOnUpdate
|
||
.calledWith(
|
||
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
ctx.recurlySubscription.addOns[0].quantity + ctx.adding
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
|
||
describe('previewAddSeatsSubscriptionChange', function () {
|
||
it('should return the subscription change preview', async function (ctx) {
|
||
const preview =
|
||
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
|
||
ctx.adminUser_id,
|
||
ctx.adding
|
||
)
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith('getPaymentFromRecord', {
|
||
groupPlan: true,
|
||
recurlyStatus: {
|
||
state: 'active',
|
||
},
|
||
})
|
||
.should.equal(true)
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith('previewSubscriptionChangeRequest', ctx.changeRequest)
|
||
.should.equal(true)
|
||
ctx.SubscriptionController.makeChangePreview
|
||
.calledWith(
|
||
{
|
||
type: 'add-on-update',
|
||
addOn: {
|
||
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
quantity:
|
||
ctx.previewSubscriptionChange.nextAddOns[0].quantity,
|
||
prevQuantity: ctx.prevQuantity,
|
||
},
|
||
},
|
||
ctx.previewSubscriptionChange
|
||
)
|
||
.should.equal(true)
|
||
preview.should.equal(ctx.changePreview)
|
||
})
|
||
})
|
||
|
||
describe('createAddSeatsSubscriptionChange', function () {
|
||
it('should change the subscription', async function (ctx) {
|
||
ctx.recurlySubscription = {
|
||
...ctx.recurlySubscription,
|
||
get isCollectionMethodManual() {
|
||
return true
|
||
},
|
||
}
|
||
ctx.Modules.promises.hooks.fire
|
||
.withArgs('getPaymentFromRecord')
|
||
.resolves([
|
||
{
|
||
subscription: ctx.recurlySubscription,
|
||
account: { hasPastDueInvoice: false },
|
||
},
|
||
])
|
||
|
||
const result =
|
||
await ctx.Handler.promises.createAddSeatsSubscriptionChange(
|
||
ctx.adminUser_id,
|
||
ctx.adding,
|
||
'123'
|
||
)
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith(
|
||
'updateSubscriptionDetails',
|
||
sinon.match
|
||
.has('poNumber')
|
||
.and(sinon.match.has('termsAndConditions'))
|
||
)
|
||
.should.equal(true)
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith(
|
||
'applySubscriptionChangeRequestAndSync',
|
||
ctx.changeRequest
|
||
)
|
||
.should.equal(true)
|
||
expect(result).to.deep.equal({
|
||
adding: ctx.req.body.adding,
|
||
})
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('updateSubscriptionPaymentTerms', function () {
|
||
describe('accounts with PO number', function () {
|
||
it('should update the subscription PO number and T&C', async function (ctx) {
|
||
await ctx.Handler.promises.updateSubscriptionPaymentTerms(
|
||
ctx.recurlySubscription,
|
||
ctx.poNumberAndTermsAndConditionsUpdate.poNumber
|
||
)
|
||
ctx.recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate
|
||
.calledWithMatch(
|
||
ctx.poNumberAndTermsAndConditionsUpdate.poNumber,
|
||
'T&Cs'
|
||
)
|
||
.should.equal(true)
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith(
|
||
'updateSubscriptionDetails',
|
||
ctx.poNumberAndTermsAndConditionsUpdate
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
})
|
||
|
||
describe('accounts with no PO number', function () {
|
||
it('should update the subscription T&C only', async function (ctx) {
|
||
await ctx.Handler.promises.updateSubscriptionPaymentTerms(
|
||
ctx.recurlySubscription
|
||
)
|
||
ctx.recurlySubscription.getRequestForTermsAndConditionsUpdate
|
||
.calledWithMatch('T&Cs')
|
||
.should.equal(true)
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith(
|
||
'updateSubscriptionDetails',
|
||
ctx.termsAndConditionsUpdate
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('has no "additional-license" add-on', function () {
|
||
beforeEach(function (ctx) {
|
||
ctx.recurlySubscription.addOns = []
|
||
ctx.prevQuantity = ctx.recurlySubscription.addOns[0]?.quantity ?? 0
|
||
ctx.previewSubscriptionChange.nextAddOns = [
|
||
{
|
||
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
quantity: ctx.prevQuantity + ctx.adding,
|
||
},
|
||
]
|
||
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
|
||
...ctx.localPlanInSettings,
|
||
planCode: 'group_collaborator_5_enterprise',
|
||
canUseFlexibleLicensing: true,
|
||
})
|
||
})
|
||
|
||
afterEach(function (ctx) {
|
||
sinon.assert.notCalled(ctx.recurlySubscription.getRequestForAddOnUpdate)
|
||
})
|
||
|
||
describe('previewAddSeatsSubscriptionChange', function () {
|
||
let preview
|
||
|
||
afterEach(function (ctx) {
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith('getPaymentFromRecord', {
|
||
groupPlan: true,
|
||
recurlyStatus: {
|
||
state: 'active',
|
||
},
|
||
})
|
||
.should.equal(true)
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith('previewSubscriptionChangeRequest', ctx.changeRequest)
|
||
.should.equal(true)
|
||
ctx.SubscriptionController.makeChangePreview
|
||
.calledWith(
|
||
{
|
||
type: 'add-on-update',
|
||
addOn: {
|
||
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
quantity:
|
||
ctx.previewSubscriptionChange.nextAddOns[0].quantity,
|
||
prevQuantity: ctx.prevQuantity,
|
||
},
|
||
},
|
||
ctx.previewSubscriptionChange
|
||
)
|
||
.should.equal(true)
|
||
preview.should.equal(ctx.changePreview)
|
||
})
|
||
|
||
it('should return the subscription change preview with legacy add-on price', async function (ctx) {
|
||
ctx.recurlySubscription.planPrice =
|
||
ctx.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
|
||
100 -
|
||
1
|
||
|
||
preview =
|
||
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
|
||
ctx.adminUser_id,
|
||
ctx.adding
|
||
)
|
||
ctx.recurlySubscription.getRequestForAddOnPurchase
|
||
.calledWithExactly(
|
||
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
ctx.adding,
|
||
ctx.GroupPlansData.enterprise.collaborator.USD[5]
|
||
.additional_license_legacy_price_in_cents / 100
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
|
||
it('should return the subscription change preview with non-legacy add-on price', async function (ctx) {
|
||
ctx.recurlySubscription.planPrice =
|
||
ctx.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
|
||
100
|
||
|
||
preview =
|
||
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
|
||
ctx.adminUser_id,
|
||
ctx.adding
|
||
)
|
||
ctx.recurlySubscription.getRequestForAddOnPurchase
|
||
.calledWithExactly(
|
||
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
ctx.adding,
|
||
undefined
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
|
||
it('should return the subscription change preview with legacy add-on price for small educational group', async function (ctx) {
|
||
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
|
||
...ctx.localPlanInSettings,
|
||
planCode: 'group_collaborator_5_educational',
|
||
canUseFlexibleLicensing: true,
|
||
})
|
||
ctx.recurlySubscription.planPrice =
|
||
ctx.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
|
||
100 +
|
||
1
|
||
|
||
preview =
|
||
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
|
||
ctx.adminUser_id,
|
||
ctx.adding
|
||
)
|
||
ctx.recurlySubscription.getRequestForAddOnPurchase
|
||
.calledWithExactly(
|
||
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
ctx.adding,
|
||
ctx.GroupPlansData.enterprise.collaborator.USD[5]
|
||
.additional_license_legacy_price_in_cents / 100
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
|
||
it('should return the subscription change preview with non-legacy add-on price for small educational group', async function (ctx) {
|
||
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
|
||
...ctx.localPlanInSettings,
|
||
planCode: 'group_collaborator_5_educational',
|
||
canUseFlexibleLicensing: true,
|
||
})
|
||
ctx.recurlySubscription.planPrice =
|
||
ctx.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
|
||
100
|
||
|
||
preview =
|
||
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
|
||
ctx.adminUser_id,
|
||
ctx.adding
|
||
)
|
||
ctx.recurlySubscription.getRequestForAddOnPurchase
|
||
.calledWithExactly(
|
||
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||
ctx.adding,
|
||
undefined
|
||
)
|
||
.should.equal(true)
|
||
})
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('ensureFlexibleLicensingEnabled', function () {
|
||
it('should throw if the subscription can not use flexible licensing', async function (ctx) {
|
||
await expect(
|
||
ctx.Handler.promises.ensureFlexibleLicensingEnabled({
|
||
canUseFlexibleLicensing: false,
|
||
})
|
||
).to.be.rejectedWith('The group plan does not support flexible licensing')
|
||
})
|
||
|
||
it('should not throw if the subscription can use flexible licensing', async function (ctx) {
|
||
await expect(
|
||
ctx.Handler.promises.ensureFlexibleLicensingEnabled({
|
||
canUseFlexibleLicensing: true,
|
||
})
|
||
).to.not.be.rejected
|
||
})
|
||
})
|
||
|
||
describe('ensureSubscriptionIsActive', function () {
|
||
it('should throw if the subscription is not active', async function (ctx) {
|
||
await expect(
|
||
ctx.Handler.promises.ensureSubscriptionIsActive(ctx.subscription)
|
||
).to.be.rejectedWith('The subscription is not active')
|
||
})
|
||
|
||
it('should not throw if the subscription is active', async function (ctx) {
|
||
await expect(
|
||
ctx.Handler.promises.ensureSubscriptionIsActive({
|
||
recurlyStatus: { state: 'active' },
|
||
})
|
||
).to.not.be.rejected
|
||
})
|
||
})
|
||
|
||
describe('ensureSubscriptionCollectionMethodIsNotManual', function () {
|
||
it('should throw if the subscription is manually collected', async function (ctx) {
|
||
await expect(
|
||
ctx.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
|
||
get isCollectionMethodManual() {
|
||
return true
|
||
},
|
||
})
|
||
).to.be.rejectedWith('This subscription is being collected manually')
|
||
})
|
||
|
||
it('should not throw if the subscription is automatically collected', async function (ctx) {
|
||
await expect(
|
||
ctx.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
|
||
get isCollectionMethodManual() {
|
||
return false
|
||
},
|
||
})
|
||
).to.not.be.rejected
|
||
})
|
||
})
|
||
|
||
describe('ensureSubscriptionHasNoPastDueInvoice', function () {
|
||
it('should throw if the subscription has past due invoice', async function (ctx) {
|
||
ctx.Modules.promises.hooks.fire
|
||
.withArgs('getPaymentFromRecord')
|
||
.resolves([{ account: { hasPastDueInvoice: true } }])
|
||
await expect(
|
||
ctx.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
|
||
ctx.subscription
|
||
)
|
||
).to.be.rejectedWith('This subscription has a past due invoice')
|
||
})
|
||
|
||
it('should not throw if the subscription has no past due invoice', async function (ctx) {
|
||
await expect(
|
||
ctx.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
|
||
ctx.subscription
|
||
)
|
||
).to.not.be.rejected
|
||
})
|
||
})
|
||
|
||
describe('ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual', function () {
|
||
it('should throw if the subscription is manually collected and has no additional license add-on', async function (ctx) {
|
||
await expect(
|
||
ctx.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 (ctx) {
|
||
await expect(
|
||
ctx.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 (ctx) {
|
||
await expect(
|
||
ctx.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
|
||
{
|
||
isCollectionMethodManual: true,
|
||
hasAddOn: sinon.stub().withArgs('additional-license').returns(true),
|
||
}
|
||
)
|
||
).to.not.be.rejected
|
||
})
|
||
})
|
||
|
||
describe('getGroupPlanUpgradePreview', function () {
|
||
it('should generate preview for subscription upgrade', async function (ctx) {
|
||
const result = await ctx.Handler.promises.getGroupPlanUpgradePreview(
|
||
ctx.user_id
|
||
)
|
||
result.should.equal(ctx.changePreview)
|
||
})
|
||
})
|
||
|
||
describe('checkBillingInfoExistence', function () {
|
||
it('should invoke the payment method function when collection method is "automatic"', async function (ctx) {
|
||
await ctx.Handler.promises.checkBillingInfoExistence(
|
||
ctx.recurlySubscription,
|
||
ctx.adminUser_id
|
||
)
|
||
ctx.Modules.promises.hooks.fire
|
||
.calledWith('getPaymentMethod', ctx.adminUser_id)
|
||
.should.equal(true)
|
||
})
|
||
|
||
it('shouldn’t invoke the payment method function when collection method is "manual"', async function (ctx) {
|
||
const recurlySubscription = {
|
||
...ctx.recurlySubscription,
|
||
get isCollectionMethodManual() {
|
||
return true
|
||
},
|
||
}
|
||
await ctx.Handler.promises.checkBillingInfoExistence(
|
||
recurlySubscription,
|
||
ctx.adminUser_id
|
||
)
|
||
ctx.RecurlyClient.promises.getPaymentMethod.should.not.have.been.called
|
||
})
|
||
})
|
||
|
||
describe('updateGroupMembersBulk', function () {
|
||
const inviterId = new ObjectId()
|
||
|
||
let members
|
||
let emailList
|
||
let callUpdateGroupMembersBulk
|
||
|
||
beforeEach(function (ctx) {
|
||
members = [
|
||
{
|
||
_id: new ObjectId(),
|
||
email: 'user1@example.com',
|
||
emails: [{ email: 'user1@example.com' }],
|
||
},
|
||
{
|
||
_id: new ObjectId(),
|
||
email: 'user2-alias@example.com',
|
||
emails: [
|
||
{
|
||
email: 'user2-alias@example.com',
|
||
},
|
||
{
|
||
email: 'user2@example.com',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
_id: new ObjectId(),
|
||
email: 'user3@example.com',
|
||
emails: [{ email: 'user3@example.com' }],
|
||
},
|
||
]
|
||
|
||
emailList = [
|
||
'user1@example.com',
|
||
'user2@example.com',
|
||
'new-user@example.com', // primary email of existing user
|
||
'new-user-2@example.com', // secondary email of existing user
|
||
]
|
||
callUpdateGroupMembersBulk = async (options = {}) => {
|
||
ctx.Subscription.findOne = sinon
|
||
.stub()
|
||
.returns({ exec: sinon.stub().resolves(ctx.subscription) })
|
||
|
||
ctx.User.find = sinon
|
||
.stub()
|
||
.returns({ exec: sinon.stub().resolves(members) })
|
||
|
||
return await ctx.Handler.promises.updateGroupMembersBulk(
|
||
inviterId,
|
||
ctx.subscription._id,
|
||
emailList,
|
||
options
|
||
)
|
||
}
|
||
})
|
||
|
||
it('throws an error when any of the emails is invalid', async function () {
|
||
emailList.push('invalid@email')
|
||
|
||
await expect(
|
||
callUpdateGroupMembersBulk({ commit: true })
|
||
).to.be.rejectedWith(InvalidEmailError)
|
||
})
|
||
|
||
describe('with commit = false', function () {
|
||
describe('with removeMembersNotIncluded = false', function () {
|
||
it('should preview zero users to delete, and should not send invites', async function (ctx) {
|
||
const result = await callUpdateGroupMembersBulk()
|
||
|
||
expect(result).to.deep.equal({
|
||
emailsToSendInvite: [
|
||
'new-user@example.com',
|
||
'new-user-2@example.com',
|
||
],
|
||
emailsToRevokeInvite: [],
|
||
membersToRemove: [],
|
||
currentMemberCount: 3,
|
||
newTotalCount: 5,
|
||
membersLimit: ctx.subscription.membersLimit,
|
||
})
|
||
|
||
expect(ctx.TeamInvitesHandler.promises.createInvite).not.to.have.been
|
||
.called
|
||
|
||
expect(ctx.SubscriptionUpdater.promises.removeUserFromGroup).not.to
|
||
.have.been.called
|
||
})
|
||
})
|
||
|
||
describe('with removeMembersNotIncluded = true', function () {
|
||
it('should preview the users to be deleted, and should not send invites', async function (ctx) {
|
||
const result = await callUpdateGroupMembersBulk({
|
||
removeMembersNotIncluded: true,
|
||
})
|
||
|
||
expect(result).to.deep.equal({
|
||
emailsToSendInvite: [
|
||
'new-user@example.com',
|
||
'new-user-2@example.com',
|
||
],
|
||
emailsToRevokeInvite: [],
|
||
membersToRemove: [members[2]._id],
|
||
currentMemberCount: 3,
|
||
newTotalCount: 4,
|
||
membersLimit: ctx.subscription.membersLimit,
|
||
})
|
||
|
||
expect(ctx.TeamInvitesHandler.promises.createInvite).not.to.have.been
|
||
.called
|
||
|
||
expect(ctx.SubscriptionUpdater.promises.removeUserFromGroup).not.to
|
||
.have.been.called
|
||
})
|
||
|
||
it('should preview but not revoke invites to emails that are no longer invited', async function (ctx) {
|
||
ctx.subscription.teamInvites = [
|
||
{ email: 'new-user@example.com' },
|
||
{ email: 'no-longer-invited@example.com' },
|
||
]
|
||
|
||
const result = await callUpdateGroupMembersBulk({
|
||
removeMembersNotIncluded: true,
|
||
})
|
||
|
||
expect(result.emailsToRevokeInvite).to.deep.equal([
|
||
'no-longer-invited@example.com',
|
||
])
|
||
|
||
expect(ctx.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
|
||
.called
|
||
})
|
||
})
|
||
|
||
it('does not throw an error when the member limit is reached', async function (ctx) {
|
||
ctx.subscription.membersLimit = 3
|
||
const result = await callUpdateGroupMembersBulk()
|
||
|
||
expect(result.membersLimit).to.equal(3)
|
||
expect(result.newTotalCount).to.equal(5)
|
||
})
|
||
})
|
||
|
||
describe('with commit = true', function () {
|
||
describe('with removeMembersNotIncluded = false', function () {
|
||
it('should preview zero users to delete, and should send invites', async function (ctx) {
|
||
const result = await callUpdateGroupMembersBulk({ commit: true })
|
||
|
||
expect(result).to.deep.equal({
|
||
emailsToSendInvite: [
|
||
'new-user@example.com',
|
||
'new-user-2@example.com',
|
||
],
|
||
emailsToRevokeInvite: [],
|
||
membersToRemove: [],
|
||
currentMemberCount: 3,
|
||
newTotalCount: 5,
|
||
membersLimit: ctx.subscription.membersLimit,
|
||
})
|
||
|
||
expect(ctx.SubscriptionUpdater.promises.removeUserFromGroup).not.to
|
||
.have.been.called
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite.callCount
|
||
).to.equal(2)
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite
|
||
).to.have.been.calledWith(
|
||
inviterId,
|
||
ctx.subscription,
|
||
'new-user@example.com'
|
||
)
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite
|
||
).to.have.been.calledWith(
|
||
inviterId,
|
||
ctx.subscription,
|
||
'new-user-2@example.com'
|
||
)
|
||
})
|
||
|
||
it('should not send invites to emails already invited', async function (ctx) {
|
||
ctx.subscription.teamInvites = [{ email: 'new-user@example.com' }]
|
||
|
||
const result = await callUpdateGroupMembersBulk({ commit: true })
|
||
|
||
expect(result.emailsToSendInvite).to.deep.equal([
|
||
'new-user-2@example.com',
|
||
])
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite.callCount
|
||
).to.equal(1)
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite
|
||
).to.have.been.calledWith(
|
||
inviterId,
|
||
ctx.subscription,
|
||
'new-user-2@example.com'
|
||
)
|
||
})
|
||
|
||
it('should preview and not revoke invites to emails that are no longer invited', async function (ctx) {
|
||
ctx.subscription.teamInvites = [
|
||
{ email: 'new-user@example.com' },
|
||
{ email: 'no-longer-invited@example.com' },
|
||
]
|
||
|
||
const result = await callUpdateGroupMembersBulk({
|
||
commit: true,
|
||
})
|
||
|
||
expect(result.emailsToRevokeInvite).to.deep.equal([])
|
||
|
||
expect(ctx.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
|
||
.called
|
||
})
|
||
})
|
||
|
||
describe('with removeMembersNotIncluded = true', function () {
|
||
it('should remove users from group, and should send invites', async function (ctx) {
|
||
const result = await callUpdateGroupMembersBulk({
|
||
commit: true,
|
||
removeMembersNotIncluded: true,
|
||
})
|
||
|
||
expect(result).to.deep.equal({
|
||
emailsToSendInvite: [
|
||
'new-user@example.com',
|
||
'new-user-2@example.com',
|
||
],
|
||
emailsToRevokeInvite: [],
|
||
membersToRemove: [members[2]._id],
|
||
currentMemberCount: 3,
|
||
newTotalCount: 4,
|
||
membersLimit: ctx.subscription.membersLimit,
|
||
})
|
||
|
||
expect(
|
||
ctx.SubscriptionUpdater.promises.removeUserFromGroup.callCount
|
||
).to.equal(1)
|
||
|
||
expect(
|
||
ctx.SubscriptionUpdater.promises.removeUserFromGroup
|
||
).to.have.been.calledWith(ctx.subscription._id, members[2]._id, {
|
||
initiatorId: inviterId,
|
||
})
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite.callCount
|
||
).to.equal(2)
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite
|
||
).to.have.been.calledWith(
|
||
inviterId,
|
||
ctx.subscription,
|
||
'new-user@example.com'
|
||
)
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite
|
||
).to.have.been.calledWith(
|
||
inviterId,
|
||
ctx.subscription,
|
||
'new-user-2@example.com'
|
||
)
|
||
})
|
||
|
||
it('should send invites and revoke invites to emails no longer invited', async function (ctx) {
|
||
ctx.subscription.teamInvites = [
|
||
{ email: 'new-user@example.com' },
|
||
{ email: 'no-longer-invited@example.com' },
|
||
]
|
||
|
||
const result = await callUpdateGroupMembersBulk({
|
||
commit: true,
|
||
removeMembersNotIncluded: true,
|
||
})
|
||
|
||
expect(result.emailsToSendInvite).to.deep.equal([
|
||
'new-user-2@example.com',
|
||
])
|
||
|
||
expect(result.emailsToRevokeInvite).to.deep.equal([
|
||
'no-longer-invited@example.com',
|
||
])
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite.callCount
|
||
).to.equal(1)
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.createInvite
|
||
).to.have.been.calledWith(
|
||
inviterId,
|
||
ctx.subscription,
|
||
'new-user-2@example.com'
|
||
)
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.revokeInvite.callCount
|
||
).to.equal(1)
|
||
|
||
expect(
|
||
ctx.TeamInvitesHandler.promises.revokeInvite
|
||
).to.have.been.calledWith(
|
||
inviterId,
|
||
ctx.subscription,
|
||
'no-longer-invited@example.com'
|
||
)
|
||
})
|
||
})
|
||
|
||
it('throws an error when the member limit is reached', async function (ctx) {
|
||
ctx.subscription.membersLimit = 3
|
||
await expect(
|
||
callUpdateGroupMembersBulk({ commit: true })
|
||
).to.be.rejectedWith('limit reached')
|
||
})
|
||
})
|
||
})
|
||
})
|