SubscriptionGroupHandler.test.mjs 39 KB

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