SubscriptionGroupHandlerTests.js 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207
  1. const SandboxedModule = require('sandboxed-module')
  2. const { ObjectId } = require('mongodb-legacy')
  3. const sinon = require('sinon')
  4. const { expect } = require('chai')
  5. const MockRequest = require('../helpers/MockRequest')
  6. const {
  7. InvalidEmailError,
  8. } = require('../../../../app/src/Features/Errors/Errors')
  9. const modulePath =
  10. '../../../../app/src/Features/Subscription/SubscriptionGroupHandler'
  11. describe('SubscriptionGroupHandler', function () {
  12. beforeEach(function () {
  13. this.adminUser_id = '12321'
  14. this.newEmail = 'bob@smith.com'
  15. this.user_id = '3121321'
  16. this.email = 'jim@example.com'
  17. this.user = { _id: this.user_id, email: this.newEmail }
  18. this.subscription_id = '31DSd1123D'
  19. this.adding = 1
  20. this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
  21. this.PaymentProviderEntities = {
  22. MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
  23. }
  24. this.localPlanInSettings = {
  25. membersLimit: 5,
  26. membersLimitAddOn: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  27. }
  28. this.subscription = {
  29. admin_id: this.adminUser_id,
  30. manager_ids: [this.adminUser_id],
  31. _id: this.subscription_id,
  32. membersLimit: 100,
  33. }
  34. this.changeRequest = {
  35. timeframe: 'now',
  36. subscription: {
  37. id: 'test_id',
  38. },
  39. }
  40. this.termsAndConditionsUpdate = {
  41. termsAndConditions: 'T&C copy',
  42. }
  43. this.poNumberAndTermsAndConditionsUpdate = {
  44. poNumber: '4444',
  45. ...this.termsAndConditionsUpdate,
  46. }
  47. this.recurlySubscription = {
  48. id: 123,
  49. addOns: [
  50. {
  51. code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  52. quantity: 1,
  53. },
  54. ],
  55. getRequestForAddOnUpdate: sinon.stub().returns(this.changeRequest),
  56. getRequestForGroupPlanUpgrade: sinon.stub().returns(this.changeRequest),
  57. getRequestForAddOnPurchase: sinon.stub().returns(this.changeRequest),
  58. getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
  59. .stub()
  60. .returns(this.changeRequest),
  61. getRequestForPoNumberAndTermsAndConditionsUpdate: sinon
  62. .stub()
  63. .returns(this.poNumberAndTermsAndConditionsUpdate),
  64. getRequestForTermsAndConditionsUpdate: sinon
  65. .stub()
  66. .returns(this.termsAndConditionsUpdate),
  67. currency: 'USD',
  68. hasAddOn(code) {
  69. return this.addOns.some(addOn => addOn.code === code)
  70. },
  71. get isCollectionMethodManual() {
  72. return false
  73. },
  74. }
  75. this.SubscriptionLocator = {
  76. promises: {
  77. getUsersSubscription: sinon.stub().resolves({
  78. groupPlan: true,
  79. recurlyStatus: {
  80. state: 'active',
  81. },
  82. }),
  83. getSubscriptionByMemberIdAndId: sinon.stub(),
  84. getSubscription: sinon.stub().resolves(this.subscription),
  85. },
  86. }
  87. this.changePreview = {
  88. currency: 'USD',
  89. }
  90. this.SubscriptionController = {
  91. makeChangePreview: sinon.stub().resolves(this.changePreview),
  92. getPlanNameForDisplay: sinon.stub().resolves(),
  93. }
  94. this.SubscriptionUpdater = {
  95. promises: {
  96. removeUserFromGroup: sinon.stub().resolves(),
  97. getSubscription: sinon.stub().resolves(),
  98. },
  99. }
  100. this.Subscription = {
  101. updateOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
  102. updateMany: sinon.stub().returns({ exec: sinon.stub().resolves }),
  103. findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
  104. }
  105. this.User = {
  106. find: sinon.stub().returns({ exec: sinon.stub().resolves }),
  107. }
  108. this.SessionManager = {
  109. getLoggedInUserId: sinon.stub().returns(this.user._id),
  110. }
  111. this.previewSubscriptionChange = {
  112. nextAddOns: [
  113. {
  114. code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  115. quantity: this.recurlySubscription.addOns[0].quantity + this.adding,
  116. },
  117. ],
  118. subscription: {
  119. planName: 'test plan',
  120. },
  121. }
  122. this.applySubscriptionChange = {}
  123. this.RecurlyClient = {
  124. promises: {
  125. getSubscription: sinon.stub().resolves(this.recurlySubscription),
  126. getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
  127. previewSubscriptionChange: sinon
  128. .stub()
  129. .resolves(this.previewSubscriptionChange),
  130. applySubscriptionChangeRequest: sinon
  131. .stub()
  132. .resolves(this.applySubscriptionChange),
  133. updateSubscriptionDetails: sinon.stub().resolves(),
  134. },
  135. }
  136. this.PlansLocator = {
  137. findLocalPlanInSettings: sinon.stub().returns(this.localPlanInSettings),
  138. }
  139. this.SubscriptionHandler = {
  140. promises: {
  141. syncSubscription: sinon.stub().resolves(),
  142. },
  143. }
  144. this.TeamInvitesHandler = {
  145. promises: {
  146. revokeInvite: sinon.stub().resolves(),
  147. createInvite: sinon.stub().resolves(),
  148. },
  149. }
  150. this.GroupPlansData = {
  151. enterprise: {
  152. collaborator: {
  153. USD: {
  154. 5: {
  155. price_in_cents: 10000,
  156. additional_license_legacy_price_in_cents: 5000,
  157. },
  158. },
  159. },
  160. },
  161. educational: {
  162. collaborator: {
  163. USD: {
  164. 5: {
  165. price_in_cents: 10000,
  166. additional_license_legacy_price_in_cents: 5000,
  167. },
  168. },
  169. },
  170. },
  171. }
  172. this.Modules = {
  173. promises: {
  174. hooks: {
  175. fire: sinon.stub(),
  176. },
  177. },
  178. }
  179. this.Modules.promises.hooks.fire
  180. .withArgs('generateTermsAndConditions')
  181. .resolves(['T&Cs'])
  182. .withArgs('getPaymentFromRecord')
  183. .resolves([
  184. {
  185. subscription: this.recurlySubscription,
  186. account: { hasPastDueInvoice: false },
  187. },
  188. ])
  189. .withArgs('previewSubscriptionChangeRequest')
  190. .resolves([this.previewSubscriptionChange])
  191. .withArgs('previewGroupPlanUpgrade')
  192. .resolves([{ subscriptionChange: this.previewSubscriptionChange }])
  193. this.Handler = SandboxedModule.require(modulePath, {
  194. requires: {
  195. './SubscriptionUpdater': this.SubscriptionUpdater,
  196. './SubscriptionLocator': this.SubscriptionLocator,
  197. './SubscriptionController': this.SubscriptionController,
  198. './SubscriptionHandler': this.SubscriptionHandler,
  199. './TeamInvitesHandler': this.TeamInvitesHandler,
  200. '../../models/Subscription': {
  201. Subscription: this.Subscription,
  202. },
  203. '../../models/User': {
  204. User: this.User,
  205. },
  206. './RecurlyClient': this.RecurlyClient,
  207. './PlansLocator': this.PlansLocator,
  208. './PaymentProviderEntities': this.PaymentProviderEntities,
  209. '../Authentication/SessionManager': this.SessionManager,
  210. './GroupPlansData': this.GroupPlansData,
  211. '../../infrastructure/Modules': this.Modules,
  212. },
  213. })
  214. })
  215. describe('removeUserFromGroup', function () {
  216. it('should call the subscription updater to remove the user', async function () {
  217. const auditLog = { ipAddress: '0:0:0:0', initiatorId: this.user._id }
  218. await this.Handler.promises.removeUserFromGroup(
  219. this.adminUser_id,
  220. this.user._id,
  221. auditLog
  222. )
  223. this.SubscriptionUpdater.promises.removeUserFromGroup
  224. .calledWith(this.adminUser_id, this.user._id, auditLog)
  225. .should.equal(true)
  226. })
  227. })
  228. describe('replaceUserReferencesInGroups', function () {
  229. beforeEach(async function () {
  230. this.oldId = 'ba5eba11'
  231. this.newId = '5ca1ab1e'
  232. await this.Handler.promises.replaceUserReferencesInGroups(
  233. this.oldId,
  234. this.newId
  235. )
  236. })
  237. it('replaces the admin_id', function () {
  238. this.Subscription.updateOne
  239. .calledWith({ admin_id: this.oldId }, { admin_id: this.newId })
  240. .should.equal(true)
  241. })
  242. it('replaces the manager_ids', function () {
  243. this.Subscription.updateMany
  244. .calledWith(
  245. { manager_ids: 'ba5eba11' },
  246. { $addToSet: { manager_ids: '5ca1ab1e' } }
  247. )
  248. .should.equal(true)
  249. this.Subscription.updateMany
  250. .calledWith(
  251. { manager_ids: 'ba5eba11' },
  252. { $pull: { manager_ids: 'ba5eba11' } }
  253. )
  254. .should.equal(true)
  255. })
  256. it('replaces the member ids', function () {
  257. this.Subscription.updateMany
  258. .calledWith(
  259. { member_ids: this.oldId },
  260. { $addToSet: { member_ids: this.newId } }
  261. )
  262. .should.equal(true)
  263. this.Subscription.updateMany
  264. .calledWith(
  265. { member_ids: this.oldId },
  266. { $pull: { member_ids: this.oldId } }
  267. )
  268. .should.equal(true)
  269. })
  270. })
  271. describe('isUserPartOfGroup', function () {
  272. beforeEach(function () {
  273. this.subscription_id = '123ed13123'
  274. })
  275. it('should return true when user is part of subscription', async function () {
  276. this.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves(
  277. {
  278. _id: this.subscription_id,
  279. }
  280. )
  281. const partOfGroup = await this.Handler.promises.isUserPartOfGroup(
  282. this.user_id,
  283. this.subscription_id
  284. )
  285. partOfGroup.should.equal(true)
  286. })
  287. it('should return false when no subscription is found', async function () {
  288. this.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves(
  289. null
  290. )
  291. const partOfGroup = await this.Handler.promises.isUserPartOfGroup(
  292. this.user_id,
  293. this.subscription_id
  294. )
  295. partOfGroup.should.equal(false)
  296. })
  297. })
  298. describe('getTotalConfirmedUsersInGroup', function () {
  299. describe('for existing subscriptions', function () {
  300. beforeEach(function () {
  301. this.subscription.member_ids = ['12321', '3121321']
  302. })
  303. it('should call the subscription locator and return 2 users', async function () {
  304. const count = await this.Handler.promises.getTotalConfirmedUsersInGroup(
  305. this.subscription_id
  306. )
  307. this.SubscriptionLocator.promises.getSubscription
  308. .calledWith(this.subscription_id)
  309. .should.equal(true)
  310. count.should.equal(2)
  311. })
  312. })
  313. describe('for nonexistent subscriptions', function () {
  314. it('should return undefined', async function () {
  315. const count =
  316. await this.Handler.promises.getTotalConfirmedUsersInGroup('fake-id')
  317. expect(count).not.to.exist
  318. })
  319. })
  320. })
  321. describe('getUsersGroupSubscriptionDetails', function () {
  322. beforeEach(function () {
  323. this.req = new MockRequest()
  324. this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
  325. ...this.localPlanInSettings,
  326. canUseFlexibleLicensing: true,
  327. })
  328. })
  329. it('should throw if the subscription is not a group plan', async function () {
  330. this.SubscriptionLocator.promises.getUsersSubscription = sinon
  331. .stub()
  332. .resolves({ groupPlan: false })
  333. await expect(
  334. this.Handler.promises.getUsersGroupSubscriptionDetails(
  335. this.adminUser_id
  336. )
  337. ).to.be.rejectedWith('User subscription is not a group plan')
  338. })
  339. it('should return users group subscription details', async function () {
  340. const data = await this.Handler.promises.getUsersGroupSubscriptionDetails(
  341. this.adminUser_id
  342. )
  343. expect(data).to.deep.equal({
  344. userId: this.adminUser_id,
  345. subscription: {
  346. groupPlan: true,
  347. recurlyStatus: {
  348. state: 'active',
  349. },
  350. },
  351. plan: {
  352. membersLimit: 5,
  353. membersLimitAddOn:
  354. this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  355. canUseFlexibleLicensing: true,
  356. },
  357. paymentProviderSubscription: this.recurlySubscription,
  358. })
  359. })
  360. })
  361. describe('add seats subscription change', function () {
  362. beforeEach(function () {
  363. this.req = new MockRequest()
  364. Object.assign(this.req.body, { adding: this.adding })
  365. this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
  366. ...this.localPlanInSettings,
  367. canUseFlexibleLicensing: true,
  368. })
  369. })
  370. describe('has "additional-license" add-on', function () {
  371. beforeEach(function () {
  372. this.recurlySubscription.addOns = [
  373. {
  374. code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  375. quantity: 6,
  376. },
  377. ]
  378. this.prevQuantity = this.recurlySubscription.addOns[0].quantity
  379. this.previewSubscriptionChange.nextAddOns = [
  380. {
  381. code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  382. quantity: this.prevQuantity + this.adding,
  383. },
  384. ]
  385. })
  386. afterEach(function () {
  387. sinon.assert.notCalled(
  388. this.recurlySubscription.getRequestForAddOnPurchase
  389. )
  390. this.recurlySubscription.getRequestForAddOnUpdate
  391. .calledWith(
  392. this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  393. this.recurlySubscription.addOns[0].quantity + this.adding
  394. )
  395. .should.equal(true)
  396. })
  397. describe('previewAddSeatsSubscriptionChange', function () {
  398. it('should return the subscription change preview', async function () {
  399. const preview =
  400. await this.Handler.promises.previewAddSeatsSubscriptionChange(
  401. this.adminUser_id,
  402. this.adding
  403. )
  404. this.Modules.promises.hooks.fire
  405. .calledWith('getPaymentFromRecord', {
  406. groupPlan: true,
  407. recurlyStatus: {
  408. state: 'active',
  409. },
  410. })
  411. .should.equal(true)
  412. this.Modules.promises.hooks.fire
  413. .calledWith('previewSubscriptionChangeRequest', this.changeRequest)
  414. .should.equal(true)
  415. this.SubscriptionController.makeChangePreview
  416. .calledWith(
  417. {
  418. type: 'add-on-update',
  419. addOn: {
  420. code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  421. quantity:
  422. this.previewSubscriptionChange.nextAddOns[0].quantity,
  423. prevQuantity: this.prevQuantity,
  424. },
  425. },
  426. this.previewSubscriptionChange
  427. )
  428. .should.equal(true)
  429. preview.should.equal(this.changePreview)
  430. })
  431. })
  432. describe('createAddSeatsSubscriptionChange', function () {
  433. it('should change the subscription', async function () {
  434. this.recurlySubscription = {
  435. ...this.recurlySubscription,
  436. get isCollectionMethodManual() {
  437. return true
  438. },
  439. }
  440. this.Modules.promises.hooks.fire
  441. .withArgs('getPaymentFromRecord')
  442. .resolves([
  443. {
  444. subscription: this.recurlySubscription,
  445. account: { hasPastDueInvoice: false },
  446. },
  447. ])
  448. const result =
  449. await this.Handler.promises.createAddSeatsSubscriptionChange(
  450. this.adminUser_id,
  451. this.adding,
  452. '123'
  453. )
  454. this.Modules.promises.hooks.fire
  455. .calledWith(
  456. 'updateSubscriptionDetails',
  457. sinon.match
  458. .has('poNumber')
  459. .and(sinon.match.has('termsAndConditions'))
  460. )
  461. .should.equal(true)
  462. this.Modules.promises.hooks.fire
  463. .calledWith(
  464. 'applySubscriptionChangeRequestAndSync',
  465. this.changeRequest
  466. )
  467. .should.equal(true)
  468. expect(result).to.deep.equal({
  469. adding: this.req.body.adding,
  470. })
  471. })
  472. })
  473. })
  474. describe('updateSubscriptionPaymentTerms', function () {
  475. describe('accounts with PO number', function () {
  476. it('should update the subscription PO number and T&C', async function () {
  477. await this.Handler.promises.updateSubscriptionPaymentTerms(
  478. this.recurlySubscription,
  479. this.poNumberAndTermsAndConditionsUpdate.poNumber
  480. )
  481. this.recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate
  482. .calledWithMatch(
  483. this.poNumberAndTermsAndConditionsUpdate.poNumber,
  484. 'T&Cs'
  485. )
  486. .should.equal(true)
  487. this.Modules.promises.hooks.fire
  488. .calledWith(
  489. 'updateSubscriptionDetails',
  490. this.poNumberAndTermsAndConditionsUpdate
  491. )
  492. .should.equal(true)
  493. })
  494. })
  495. describe('accounts with no PO number', function () {
  496. it('should update the subscription T&C only', async function () {
  497. await this.Handler.promises.updateSubscriptionPaymentTerms(
  498. this.recurlySubscription
  499. )
  500. this.recurlySubscription.getRequestForTermsAndConditionsUpdate
  501. .calledWithMatch('T&Cs')
  502. .should.equal(true)
  503. this.Modules.promises.hooks.fire
  504. .calledWith(
  505. 'updateSubscriptionDetails',
  506. this.termsAndConditionsUpdate
  507. )
  508. .should.equal(true)
  509. })
  510. })
  511. })
  512. describe('has no "additional-license" add-on', function () {
  513. beforeEach(function () {
  514. this.recurlySubscription.addOns = []
  515. this.prevQuantity = this.recurlySubscription.addOns[0]?.quantity ?? 0
  516. this.previewSubscriptionChange.nextAddOns = [
  517. {
  518. code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  519. quantity: this.prevQuantity + this.adding,
  520. },
  521. ]
  522. this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
  523. ...this.localPlanInSettings,
  524. planCode: 'group_collaborator_5_enterprise',
  525. canUseFlexibleLicensing: true,
  526. })
  527. })
  528. afterEach(function () {
  529. sinon.assert.notCalled(
  530. this.recurlySubscription.getRequestForAddOnUpdate
  531. )
  532. })
  533. describe('previewAddSeatsSubscriptionChange', function () {
  534. let preview
  535. afterEach(function () {
  536. this.Modules.promises.hooks.fire
  537. .calledWith('getPaymentFromRecord', {
  538. groupPlan: true,
  539. recurlyStatus: {
  540. state: 'active',
  541. },
  542. })
  543. .should.equal(true)
  544. this.Modules.promises.hooks.fire
  545. .calledWith('previewSubscriptionChangeRequest', this.changeRequest)
  546. .should.equal(true)
  547. this.SubscriptionController.makeChangePreview
  548. .calledWith(
  549. {
  550. type: 'add-on-update',
  551. addOn: {
  552. code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  553. quantity:
  554. this.previewSubscriptionChange.nextAddOns[0].quantity,
  555. prevQuantity: this.prevQuantity,
  556. },
  557. },
  558. this.previewSubscriptionChange
  559. )
  560. .should.equal(true)
  561. preview.should.equal(this.changePreview)
  562. })
  563. it('should return the subscription change preview with legacy add-on price', async function () {
  564. this.recurlySubscription.planPrice =
  565. this.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
  566. 100 -
  567. 1
  568. preview =
  569. await this.Handler.promises.previewAddSeatsSubscriptionChange(
  570. this.adminUser_id,
  571. this.adding
  572. )
  573. this.recurlySubscription.getRequestForAddOnPurchase
  574. .calledWithExactly(
  575. this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  576. this.adding,
  577. this.GroupPlansData.enterprise.collaborator.USD[5]
  578. .additional_license_legacy_price_in_cents / 100
  579. )
  580. .should.equal(true)
  581. })
  582. it('should return the subscription change preview with non-legacy add-on price', async function () {
  583. this.recurlySubscription.planPrice =
  584. this.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
  585. 100
  586. preview =
  587. await this.Handler.promises.previewAddSeatsSubscriptionChange(
  588. this.adminUser_id,
  589. this.adding
  590. )
  591. this.recurlySubscription.getRequestForAddOnPurchase
  592. .calledWithExactly(
  593. this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  594. this.adding,
  595. undefined
  596. )
  597. .should.equal(true)
  598. })
  599. it('should return the subscription change preview with legacy add-on price for small educational group', async function () {
  600. this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
  601. ...this.localPlanInSettings,
  602. planCode: 'group_collaborator_5_educational',
  603. canUseFlexibleLicensing: true,
  604. })
  605. this.recurlySubscription.planPrice =
  606. this.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
  607. 100 +
  608. 1
  609. preview =
  610. await this.Handler.promises.previewAddSeatsSubscriptionChange(
  611. this.adminUser_id,
  612. this.adding
  613. )
  614. this.recurlySubscription.getRequestForAddOnPurchase
  615. .calledWithExactly(
  616. this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  617. this.adding,
  618. this.GroupPlansData.enterprise.collaborator.USD[5]
  619. .additional_license_legacy_price_in_cents / 100
  620. )
  621. .should.equal(true)
  622. })
  623. it('should return the subscription change preview with non-legacy add-on price for small educational group', async function () {
  624. this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
  625. ...this.localPlanInSettings,
  626. planCode: 'group_collaborator_5_educational',
  627. canUseFlexibleLicensing: true,
  628. })
  629. this.recurlySubscription.planPrice =
  630. this.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
  631. 100
  632. preview =
  633. await this.Handler.promises.previewAddSeatsSubscriptionChange(
  634. this.adminUser_id,
  635. this.adding
  636. )
  637. this.recurlySubscription.getRequestForAddOnPurchase
  638. .calledWithExactly(
  639. this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  640. this.adding,
  641. undefined
  642. )
  643. .should.equal(true)
  644. })
  645. })
  646. })
  647. })
  648. describe('ensureFlexibleLicensingEnabled', function () {
  649. it('should throw if the subscription can not use flexible licensing', async function () {
  650. await expect(
  651. this.Handler.promises.ensureFlexibleLicensingEnabled({
  652. canUseFlexibleLicensing: false,
  653. })
  654. ).to.be.rejectedWith('The group plan does not support flexible licensing')
  655. })
  656. it('should not throw if the subscription can use flexible licensing', async function () {
  657. await expect(
  658. this.Handler.promises.ensureFlexibleLicensingEnabled({
  659. canUseFlexibleLicensing: true,
  660. })
  661. ).to.not.be.rejected
  662. })
  663. })
  664. describe('ensureSubscriptionIsActive', function () {
  665. it('should throw if the subscription is not active', async function () {
  666. await expect(
  667. this.Handler.promises.ensureSubscriptionIsActive(this.subscription)
  668. ).to.be.rejectedWith('The subscription is not active')
  669. })
  670. it('should not throw if the subscription is active', async function () {
  671. await expect(
  672. this.Handler.promises.ensureSubscriptionIsActive({
  673. recurlyStatus: { state: 'active' },
  674. })
  675. ).to.not.be.rejected
  676. })
  677. })
  678. describe('ensureSubscriptionCollectionMethodIsNotManual', function () {
  679. it('should throw if the subscription is manually collected', async function () {
  680. await expect(
  681. this.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
  682. get isCollectionMethodManual() {
  683. return true
  684. },
  685. })
  686. ).to.be.rejectedWith('This subscription is being collected manually')
  687. })
  688. it('should not throw if the subscription is automatically collected', async function () {
  689. await expect(
  690. this.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
  691. get isCollectionMethodManual() {
  692. return false
  693. },
  694. })
  695. ).to.not.be.rejected
  696. })
  697. })
  698. describe('ensureSubscriptionHasNoPendingChanges', function () {
  699. it('should throw if the subscription has pending change', async function () {
  700. await expect(
  701. this.Handler.promises.ensureSubscriptionHasNoPendingChanges({
  702. pendingChange: {},
  703. })
  704. ).to.be.rejectedWith('This subscription has a pending change')
  705. })
  706. it('should not throw if the subscription has no pending change', async function () {
  707. await expect(
  708. this.Handler.promises.ensureSubscriptionHasNoPendingChanges({})
  709. ).to.not.be.rejected
  710. })
  711. })
  712. describe('ensureSubscriptionHasNoPastDueInvoice', function () {
  713. it('should throw if the subscription has past due invoice', async function () {
  714. this.Modules.promises.hooks.fire
  715. .withArgs('getPaymentFromRecord')
  716. .resolves([{ account: { hasPastDueInvoice: true } }])
  717. await expect(
  718. this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
  719. this.subscription
  720. )
  721. ).to.be.rejectedWith('This subscription has a past due invoice')
  722. })
  723. it('should not throw if the subscription has no past due invoice', async function () {
  724. await expect(
  725. this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
  726. this.subscription
  727. )
  728. ).to.not.be.rejected
  729. })
  730. })
  731. describe('ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual', function () {
  732. it('should throw if the subscription is manually collected and has no additional license add-on', async function () {
  733. await expect(
  734. this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
  735. {
  736. isCollectionMethodManual: true,
  737. hasAddOn: sinon
  738. .stub()
  739. .withArgs('additional-license')
  740. .returns(false),
  741. }
  742. )
  743. ).to.be.rejectedWith(
  744. 'This subscription is being collected manually has no "additional-license" add-on'
  745. )
  746. })
  747. it('should not throw if the subscription is not manually collected and has no additional license add-on and ', async function () {
  748. await expect(
  749. this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
  750. {
  751. isCollectionMethodManual: false,
  752. hasAddOn: sinon
  753. .stub()
  754. .withArgs('additional-license')
  755. .returns(false),
  756. }
  757. )
  758. ).to.not.be.rejected
  759. })
  760. it('should not throw if the subscription is not manually collected and has additional license add-on', async function () {
  761. await expect(
  762. this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
  763. {
  764. isCollectionMethodManual: true,
  765. hasAddOn: sinon.stub().withArgs('additional-license').returns(true),
  766. }
  767. )
  768. ).to.not.be.rejected
  769. })
  770. })
  771. describe('getGroupPlanUpgradePreview', function () {
  772. it('should generate preview for subscription upgrade', async function () {
  773. const result = await this.Handler.promises.getGroupPlanUpgradePreview(
  774. this.user_id
  775. )
  776. result.should.equal(this.changePreview)
  777. })
  778. })
  779. describe('checkBillingInfoExistence', function () {
  780. it('should invoke the payment method function when collection method is "automatic"', async function () {
  781. await this.Handler.promises.checkBillingInfoExistence(
  782. this.recurlySubscription,
  783. this.adminUser_id
  784. )
  785. this.Modules.promises.hooks.fire
  786. .calledWith('getPaymentMethod', this.adminUser_id)
  787. .should.equal(true)
  788. })
  789. it('shouldn’t invoke the payment method function when collection method is "manual"', async function () {
  790. const recurlySubscription = {
  791. ...this.recurlySubscription,
  792. get isCollectionMethodManual() {
  793. return true
  794. },
  795. }
  796. await this.Handler.promises.checkBillingInfoExistence(
  797. recurlySubscription,
  798. this.adminUser_id
  799. )
  800. this.RecurlyClient.promises.getPaymentMethod.should.not.have.been.called
  801. })
  802. })
  803. describe('updateGroupMembersBulk', function () {
  804. const inviterId = new ObjectId()
  805. let members
  806. let emailList
  807. let callUpdateGroupMembersBulk
  808. beforeEach(function () {
  809. members = [
  810. {
  811. _id: new ObjectId(),
  812. email: 'user1@example.com',
  813. emails: [{ email: 'user1@example.com' }],
  814. },
  815. {
  816. _id: new ObjectId(),
  817. email: 'user2-alias@example.com',
  818. emails: [
  819. {
  820. email: 'user2-alias@example.com',
  821. },
  822. {
  823. email: 'user2@example.com',
  824. },
  825. ],
  826. },
  827. {
  828. _id: new ObjectId(),
  829. email: 'user3@example.com',
  830. emails: [{ email: 'user3@example.com' }],
  831. },
  832. ]
  833. emailList = [
  834. 'user1@example.com',
  835. 'user2@example.com',
  836. 'new-user@example.com', // primary email of existing user
  837. 'new-user-2@example.com', // secondary email of existing user
  838. ]
  839. callUpdateGroupMembersBulk = async (options = {}) => {
  840. this.Subscription.findOne = sinon
  841. .stub()
  842. .returns({ exec: sinon.stub().resolves(this.subscription) })
  843. this.User.find = sinon
  844. .stub()
  845. .returns({ exec: sinon.stub().resolves(members) })
  846. return await this.Handler.promises.updateGroupMembersBulk(
  847. inviterId,
  848. this.subscription._id,
  849. emailList,
  850. options
  851. )
  852. }
  853. })
  854. it('throws an error when any of the emails is invalid', async function () {
  855. emailList.push('invalid@email')
  856. await expect(
  857. callUpdateGroupMembersBulk({ commit: true })
  858. ).to.be.rejectedWith(InvalidEmailError)
  859. })
  860. describe('with commit = false', function () {
  861. describe('with removeMembersNotIncluded = false', function () {
  862. it('should preview zero users to delete, and should not send invites', async function () {
  863. const result = await callUpdateGroupMembersBulk()
  864. expect(result).to.deep.equal({
  865. emailsToSendInvite: [
  866. 'new-user@example.com',
  867. 'new-user-2@example.com',
  868. ],
  869. emailsToRevokeInvite: [],
  870. membersToRemove: [],
  871. currentMemberCount: 3,
  872. newTotalCount: 5,
  873. membersLimit: this.subscription.membersLimit,
  874. })
  875. expect(this.TeamInvitesHandler.promises.createInvite).not.to.have.been
  876. .called
  877. expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
  878. .have.been.called
  879. })
  880. })
  881. describe('with removeMembersNotIncluded = true', function () {
  882. it('should preview the users to be deleted, and should not send invites', async function () {
  883. const result = await callUpdateGroupMembersBulk({
  884. removeMembersNotIncluded: true,
  885. })
  886. expect(result).to.deep.equal({
  887. emailsToSendInvite: [
  888. 'new-user@example.com',
  889. 'new-user-2@example.com',
  890. ],
  891. emailsToRevokeInvite: [],
  892. membersToRemove: [members[2]._id],
  893. currentMemberCount: 3,
  894. newTotalCount: 4,
  895. membersLimit: this.subscription.membersLimit,
  896. })
  897. expect(this.TeamInvitesHandler.promises.createInvite).not.to.have.been
  898. .called
  899. expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
  900. .have.been.called
  901. })
  902. it('should preview but not revoke invites to emails that are no longer invited', async function () {
  903. this.subscription.teamInvites = [
  904. { email: 'new-user@example.com' },
  905. { email: 'no-longer-invited@example.com' },
  906. ]
  907. const result = await callUpdateGroupMembersBulk({
  908. removeMembersNotIncluded: true,
  909. })
  910. expect(result.emailsToRevokeInvite).to.deep.equal([
  911. 'no-longer-invited@example.com',
  912. ])
  913. expect(this.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
  914. .called
  915. })
  916. })
  917. it('does not throw an error when the member limit is reached', async function () {
  918. this.subscription.membersLimit = 3
  919. const result = await callUpdateGroupMembersBulk()
  920. expect(result.membersLimit).to.equal(3)
  921. expect(result.newTotalCount).to.equal(5)
  922. })
  923. })
  924. describe('with commit = true', function () {
  925. describe('with removeMembersNotIncluded = false', function () {
  926. it('should preview zero users to delete, and should send invites', async function () {
  927. const result = await callUpdateGroupMembersBulk({ commit: true })
  928. expect(result).to.deep.equal({
  929. emailsToSendInvite: [
  930. 'new-user@example.com',
  931. 'new-user-2@example.com',
  932. ],
  933. emailsToRevokeInvite: [],
  934. membersToRemove: [],
  935. currentMemberCount: 3,
  936. newTotalCount: 5,
  937. membersLimit: this.subscription.membersLimit,
  938. })
  939. expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
  940. .have.been.called
  941. expect(
  942. this.TeamInvitesHandler.promises.createInvite.callCount
  943. ).to.equal(2)
  944. expect(
  945. this.TeamInvitesHandler.promises.createInvite
  946. ).to.have.been.calledWith(
  947. inviterId,
  948. this.subscription,
  949. 'new-user@example.com'
  950. )
  951. expect(
  952. this.TeamInvitesHandler.promises.createInvite
  953. ).to.have.been.calledWith(
  954. inviterId,
  955. this.subscription,
  956. 'new-user-2@example.com'
  957. )
  958. })
  959. it('should not send invites to emails already invited', async function () {
  960. this.subscription.teamInvites = [{ email: 'new-user@example.com' }]
  961. const result = await callUpdateGroupMembersBulk({ commit: true })
  962. expect(result.emailsToSendInvite).to.deep.equal([
  963. 'new-user-2@example.com',
  964. ])
  965. expect(
  966. this.TeamInvitesHandler.promises.createInvite.callCount
  967. ).to.equal(1)
  968. expect(
  969. this.TeamInvitesHandler.promises.createInvite
  970. ).to.have.been.calledWith(
  971. inviterId,
  972. this.subscription,
  973. 'new-user-2@example.com'
  974. )
  975. })
  976. it('should preview and not revoke invites to emails that are no longer invited', async function () {
  977. this.subscription.teamInvites = [
  978. { email: 'new-user@example.com' },
  979. { email: 'no-longer-invited@example.com' },
  980. ]
  981. const result = await callUpdateGroupMembersBulk({
  982. commit: true,
  983. })
  984. expect(result.emailsToRevokeInvite).to.deep.equal([])
  985. expect(this.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
  986. .called
  987. })
  988. })
  989. describe('with removeMembersNotIncluded = true', function () {
  990. it('should remove users from group, and should send invites', async function () {
  991. const result = await callUpdateGroupMembersBulk({
  992. commit: true,
  993. removeMembersNotIncluded: true,
  994. })
  995. expect(result).to.deep.equal({
  996. emailsToSendInvite: [
  997. 'new-user@example.com',
  998. 'new-user-2@example.com',
  999. ],
  1000. emailsToRevokeInvite: [],
  1001. membersToRemove: [members[2]._id],
  1002. currentMemberCount: 3,
  1003. newTotalCount: 4,
  1004. membersLimit: this.subscription.membersLimit,
  1005. })
  1006. expect(
  1007. this.SubscriptionUpdater.promises.removeUserFromGroup.callCount
  1008. ).to.equal(1)
  1009. expect(
  1010. this.SubscriptionUpdater.promises.removeUserFromGroup
  1011. ).to.have.been.calledWith(this.subscription._id, members[2]._id, {
  1012. initiatorId: inviterId,
  1013. })
  1014. expect(
  1015. this.TeamInvitesHandler.promises.createInvite.callCount
  1016. ).to.equal(2)
  1017. expect(
  1018. this.TeamInvitesHandler.promises.createInvite
  1019. ).to.have.been.calledWith(
  1020. inviterId,
  1021. this.subscription,
  1022. 'new-user@example.com'
  1023. )
  1024. expect(
  1025. this.TeamInvitesHandler.promises.createInvite
  1026. ).to.have.been.calledWith(
  1027. inviterId,
  1028. this.subscription,
  1029. 'new-user-2@example.com'
  1030. )
  1031. })
  1032. it('should send invites and revoke invites to emails no longer invited', async function () {
  1033. this.subscription.teamInvites = [
  1034. { email: 'new-user@example.com' },
  1035. { email: 'no-longer-invited@example.com' },
  1036. ]
  1037. const result = await callUpdateGroupMembersBulk({
  1038. commit: true,
  1039. removeMembersNotIncluded: true,
  1040. })
  1041. expect(result.emailsToSendInvite).to.deep.equal([
  1042. 'new-user-2@example.com',
  1043. ])
  1044. expect(result.emailsToRevokeInvite).to.deep.equal([
  1045. 'no-longer-invited@example.com',
  1046. ])
  1047. expect(
  1048. this.TeamInvitesHandler.promises.createInvite.callCount
  1049. ).to.equal(1)
  1050. expect(
  1051. this.TeamInvitesHandler.promises.createInvite
  1052. ).to.have.been.calledWith(
  1053. inviterId,
  1054. this.subscription,
  1055. 'new-user-2@example.com'
  1056. )
  1057. expect(
  1058. this.TeamInvitesHandler.promises.revokeInvite.callCount
  1059. ).to.equal(1)
  1060. expect(
  1061. this.TeamInvitesHandler.promises.revokeInvite
  1062. ).to.have.been.calledWith(
  1063. inviterId,
  1064. this.subscription,
  1065. 'no-longer-invited@example.com'
  1066. )
  1067. })
  1068. })
  1069. it('throws an error when the member limit is reached', async function () {
  1070. this.subscription.membersLimit = 3
  1071. await expect(
  1072. callUpdateGroupMembersBulk({ commit: true })
  1073. ).to.be.rejectedWith('limit reached')
  1074. })
  1075. })
  1076. })
  1077. })