SubscriptionGroupHandlerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const { expect } = require('chai')
  4. const MockRequest = require('../helpers/MockRequest')
  5. const modulePath =
  6. '../../../../app/src/Features/Subscription/SubscriptionGroupHandler'
  7. describe('SubscriptionGroupHandler', function () {
  8. beforeEach(function () {
  9. this.adminUser_id = '12321'
  10. this.newEmail = 'bob@smith.com'
  11. this.user_id = '3121321'
  12. this.email = 'jim@example.com'
  13. this.user = { _id: this.user_id, email: this.newEmail }
  14. this.subscription_id = '31DSd1123D'
  15. this.adding = 1
  16. this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
  17. this.RecurlyEntities = {
  18. MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
  19. }
  20. this.localPlanInSettings = {
  21. membersLimit: 5,
  22. membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  23. }
  24. this.subscription = {
  25. admin_id: this.adminUser_id,
  26. manager_ids: [this.adminUser_id],
  27. _id: this.subscription_id,
  28. }
  29. this.changeRequest = {
  30. timeframe: 'now',
  31. subscription: {
  32. id: 'test_id',
  33. },
  34. }
  35. this.recurlySubscription = {
  36. id: 123,
  37. addOns: [
  38. {
  39. code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  40. quantity: 1,
  41. },
  42. ],
  43. getRequestForAddOnUpdate: sinon.stub().returns(this.changeRequest),
  44. getRequestForGroupPlanUpgrade: sinon.stub().returns(this.changeRequest),
  45. getRequestForAddOnPurchase: sinon.stub().returns(this.changeRequest),
  46. getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
  47. .stub()
  48. .returns(this.changeRequest),
  49. createdAt: '2025-01-01T00:00:00Z',
  50. currency: 'USD',
  51. hasAddOn(code) {
  52. return this.addOns.some(addOn => addOn.code === code)
  53. },
  54. }
  55. this.SubscriptionLocator = {
  56. promises: {
  57. getUsersSubscription: sinon.stub().resolves({ groupPlan: true }),
  58. getSubscriptionByMemberIdAndId: sinon.stub(),
  59. getSubscription: sinon.stub().resolves(this.subscription),
  60. },
  61. }
  62. this.changePreview = {
  63. currency: 'USD',
  64. }
  65. this.SubscriptionController = {
  66. makeChangePreview: sinon.stub().resolves(this.changePreview),
  67. getPlanNameForDisplay: sinon.stub().resolves(),
  68. }
  69. this.SubscriptionUpdater = {
  70. promises: {
  71. removeUserFromGroup: sinon.stub().resolves(),
  72. getSubscription: sinon.stub().resolves(),
  73. },
  74. }
  75. this.Subscription = {
  76. updateOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
  77. updateMany: sinon.stub().returns({ exec: sinon.stub().resolves }),
  78. findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
  79. }
  80. this.SessionManager = {
  81. getLoggedInUserId: sinon.stub().returns(this.user._id),
  82. }
  83. this.previewSubscriptionChange = {
  84. nextAddOns: [
  85. {
  86. code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  87. quantity: this.recurlySubscription.addOns[0].quantity + this.adding,
  88. },
  89. ],
  90. subscription: {
  91. planName: 'test plan',
  92. },
  93. }
  94. this.applySubscriptionChange = {}
  95. this.RecurlyClient = {
  96. promises: {
  97. getSubscription: sinon.stub().resolves(this.recurlySubscription),
  98. getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
  99. previewSubscriptionChange: sinon
  100. .stub()
  101. .resolves(this.previewSubscriptionChange),
  102. applySubscriptionChangeRequest: sinon
  103. .stub()
  104. .resolves(this.applySubscriptionChange),
  105. },
  106. }
  107. this.PlansLocator = {
  108. findLocalPlanInSettings: sinon.stub().returns(this.localPlanInSettings),
  109. }
  110. this.SubscriptionHandler = {
  111. promises: {
  112. syncSubscription: sinon.stub().resolves(),
  113. },
  114. }
  115. this.GroupPlansData = {
  116. enterprise: {
  117. collaborator: {
  118. USD: {
  119. 5: {
  120. price_in_cents: 10000,
  121. additional_license_legacy_price_in_cents: 5000,
  122. },
  123. },
  124. },
  125. },
  126. }
  127. this.Handler = SandboxedModule.require(modulePath, {
  128. requires: {
  129. './SubscriptionUpdater': this.SubscriptionUpdater,
  130. './SubscriptionLocator': this.SubscriptionLocator,
  131. './SubscriptionController': this.SubscriptionController,
  132. './SubscriptionHandler': this.SubscriptionHandler,
  133. '../../models/Subscription': {
  134. Subscription: this.Subscription,
  135. },
  136. './RecurlyClient': this.RecurlyClient,
  137. './PlansLocator': this.PlansLocator,
  138. './RecurlyEntities': this.RecurlyEntities,
  139. '../Authentication/SessionManager': this.SessionManager,
  140. './GroupPlansData': this.GroupPlansData,
  141. },
  142. })
  143. })
  144. describe('removeUserFromGroup', function () {
  145. it('should call the subscription updater to remove the user', async function () {
  146. await this.Handler.promises.removeUserFromGroup(
  147. this.adminUser_id,
  148. this.user._id
  149. )
  150. this.SubscriptionUpdater.promises.removeUserFromGroup
  151. .calledWith(this.adminUser_id, this.user._id)
  152. .should.equal(true)
  153. })
  154. })
  155. describe('replaceUserReferencesInGroups', function () {
  156. beforeEach(async function () {
  157. this.oldId = 'ba5eba11'
  158. this.newId = '5ca1ab1e'
  159. await this.Handler.promises.replaceUserReferencesInGroups(
  160. this.oldId,
  161. this.newId
  162. )
  163. })
  164. it('replaces the admin_id', function () {
  165. this.Subscription.updateOne
  166. .calledWith({ admin_id: this.oldId }, { admin_id: this.newId })
  167. .should.equal(true)
  168. })
  169. it('replaces the manager_ids', function () {
  170. this.Subscription.updateMany
  171. .calledWith(
  172. { manager_ids: 'ba5eba11' },
  173. { $addToSet: { manager_ids: '5ca1ab1e' } }
  174. )
  175. .should.equal(true)
  176. this.Subscription.updateMany
  177. .calledWith(
  178. { manager_ids: 'ba5eba11' },
  179. { $pull: { manager_ids: 'ba5eba11' } }
  180. )
  181. .should.equal(true)
  182. })
  183. it('replaces the member ids', function () {
  184. this.Subscription.updateMany
  185. .calledWith(
  186. { member_ids: this.oldId },
  187. { $addToSet: { member_ids: this.newId } }
  188. )
  189. .should.equal(true)
  190. this.Subscription.updateMany
  191. .calledWith(
  192. { member_ids: this.oldId },
  193. { $pull: { member_ids: this.oldId } }
  194. )
  195. .should.equal(true)
  196. })
  197. })
  198. describe('isUserPartOfGroup', function () {
  199. beforeEach(function () {
  200. this.subscription_id = '123ed13123'
  201. })
  202. it('should return true when user is part of subscription', async function () {
  203. this.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves(
  204. {
  205. _id: this.subscription_id,
  206. }
  207. )
  208. const partOfGroup = await this.Handler.promises.isUserPartOfGroup(
  209. this.user_id,
  210. this.subscription_id
  211. )
  212. partOfGroup.should.equal(true)
  213. })
  214. it('should return false when no subscription is found', async function () {
  215. this.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves(
  216. null
  217. )
  218. const partOfGroup = await this.Handler.promises.isUserPartOfGroup(
  219. this.user_id,
  220. this.subscription_id
  221. )
  222. partOfGroup.should.equal(false)
  223. })
  224. })
  225. describe('getTotalConfirmedUsersInGroup', function () {
  226. describe('for existing subscriptions', function () {
  227. beforeEach(function () {
  228. this.subscription.member_ids = ['12321', '3121321']
  229. })
  230. it('should call the subscription locator and return 2 users', async function () {
  231. const count = await this.Handler.promises.getTotalConfirmedUsersInGroup(
  232. this.subscription_id
  233. )
  234. this.SubscriptionLocator.promises.getSubscription
  235. .calledWith(this.subscription_id)
  236. .should.equal(true)
  237. count.should.equal(2)
  238. })
  239. })
  240. describe('for nonexistent subscriptions', function () {
  241. it('should return undefined', async function () {
  242. const count =
  243. await this.Handler.promises.getTotalConfirmedUsersInGroup('fake-id')
  244. expect(count).not.to.exist
  245. })
  246. })
  247. })
  248. describe('getUsersGroupSubscriptionDetails', function () {
  249. beforeEach(function () {
  250. this.req = new MockRequest()
  251. this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
  252. ...this.localPlanInSettings,
  253. canUseFlexibleLicensing: true,
  254. })
  255. })
  256. it('should throw if the subscription is not a group plan', async function () {
  257. this.SubscriptionLocator.promises.getUsersSubscription = sinon
  258. .stub()
  259. .resolves({ groupPlan: false })
  260. await expect(
  261. this.Handler.promises.getUsersGroupSubscriptionDetails(
  262. this.adminUser_id
  263. )
  264. ).to.be.rejectedWith('User subscription is not a group plan')
  265. })
  266. it('should return users group subscription details', async function () {
  267. const data = await this.Handler.promises.getUsersGroupSubscriptionDetails(
  268. this.adminUser_id
  269. )
  270. expect(data).to.deep.equal({
  271. userId: this.adminUser_id,
  272. subscription: { groupPlan: true },
  273. plan: {
  274. membersLimit: 5,
  275. membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  276. canUseFlexibleLicensing: true,
  277. },
  278. recurlySubscription: this.recurlySubscription,
  279. })
  280. })
  281. })
  282. describe('add seats subscription change', function () {
  283. beforeEach(function () {
  284. this.req = new MockRequest()
  285. Object.assign(this.req.body, { adding: this.adding })
  286. this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
  287. ...this.localPlanInSettings,
  288. canUseFlexibleLicensing: true,
  289. })
  290. })
  291. describe('has "additional-license" add-on', function () {
  292. beforeEach(function () {
  293. this.recurlySubscription.addOns = [
  294. {
  295. code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  296. quantity: 6,
  297. },
  298. ]
  299. this.prevQuantity = this.recurlySubscription.addOns[0].quantity
  300. this.previewSubscriptionChange.nextAddOns = [
  301. {
  302. code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  303. quantity: this.prevQuantity + this.adding,
  304. },
  305. ]
  306. })
  307. afterEach(function () {
  308. sinon.assert.notCalled(
  309. this.recurlySubscription.getRequestForAddOnPurchase
  310. )
  311. this.recurlySubscription.getRequestForAddOnUpdate
  312. .calledWith(
  313. this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  314. this.recurlySubscription.addOns[0].quantity + this.adding
  315. )
  316. .should.equal(true)
  317. })
  318. describe('previewAddSeatsSubscriptionChange', function () {
  319. it('should return the subscription change preview', async function () {
  320. const preview =
  321. await this.Handler.promises.previewAddSeatsSubscriptionChange(
  322. this.adminUser_id,
  323. this.adding
  324. )
  325. this.RecurlyClient.promises.getPaymentMethod
  326. .calledWith(this.adminUser_id)
  327. .should.equal(true)
  328. this.RecurlyClient.promises.previewSubscriptionChange
  329. .calledWith(this.changeRequest)
  330. .should.equal(true)
  331. this.SubscriptionController.makeChangePreview
  332. .calledWith(
  333. {
  334. type: 'add-on-update',
  335. addOn: {
  336. code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  337. quantity:
  338. this.previewSubscriptionChange.nextAddOns[0].quantity,
  339. prevQuantity: this.prevQuantity,
  340. },
  341. },
  342. this.previewSubscriptionChange,
  343. this.paymentMethod
  344. )
  345. .should.equal(true)
  346. preview.should.equal(this.changePreview)
  347. })
  348. })
  349. describe('createAddSeatsSubscriptionChange', function () {
  350. it('should change the subscription', async function () {
  351. const result =
  352. await this.Handler.promises.createAddSeatsSubscriptionChange(
  353. this.adminUser_id,
  354. this.adding
  355. )
  356. this.RecurlyClient.promises.applySubscriptionChangeRequest
  357. .calledWith(this.changeRequest)
  358. .should.equal(true)
  359. this.SubscriptionHandler.promises.syncSubscription
  360. .calledWith(
  361. { uuid: this.recurlySubscription.id },
  362. this.adminUser_id
  363. )
  364. .should.equal(true)
  365. expect(result).to.deep.equal({
  366. adding: this.req.body.adding,
  367. })
  368. })
  369. })
  370. })
  371. describe('has no "additional-license" add-on', function () {
  372. beforeEach(function () {
  373. this.recurlySubscription.addOns = []
  374. this.prevQuantity = this.recurlySubscription.addOns[0]?.quantity ?? 0
  375. this.previewSubscriptionChange.nextAddOns = [
  376. {
  377. code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  378. quantity: this.prevQuantity + this.adding,
  379. },
  380. ]
  381. this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
  382. ...this.localPlanInSettings,
  383. planCode: 'group_collaborator_5_enterprise',
  384. canUseFlexibleLicensing: true,
  385. })
  386. })
  387. afterEach(function () {
  388. sinon.assert.notCalled(
  389. this.recurlySubscription.getRequestForAddOnUpdate
  390. )
  391. })
  392. describe('previewAddSeatsSubscriptionChange', function () {
  393. let preview
  394. afterEach(function () {
  395. this.RecurlyClient.promises.getPaymentMethod
  396. .calledWith(this.adminUser_id)
  397. .should.equal(true)
  398. this.RecurlyClient.promises.previewSubscriptionChange
  399. .calledWith(this.changeRequest)
  400. .should.equal(true)
  401. this.SubscriptionController.makeChangePreview
  402. .calledWith(
  403. {
  404. type: 'add-on-update',
  405. addOn: {
  406. code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  407. quantity:
  408. this.previewSubscriptionChange.nextAddOns[0].quantity,
  409. prevQuantity: this.prevQuantity,
  410. },
  411. },
  412. this.previewSubscriptionChange,
  413. this.paymentMethod
  414. )
  415. .should.equal(true)
  416. preview.should.equal(this.changePreview)
  417. })
  418. it('should return the subscription change preview with legacy add-on price', async function () {
  419. this.recurlySubscription.createdAt = '2025-01-01T00:00:00Z'
  420. preview =
  421. await this.Handler.promises.previewAddSeatsSubscriptionChange(
  422. this.adminUser_id,
  423. this.adding
  424. )
  425. this.recurlySubscription.getRequestForAddOnPurchase
  426. .calledWithExactly(
  427. this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  428. this.adding,
  429. this.GroupPlansData.enterprise.collaborator.USD[5]
  430. .additional_license_legacy_price_in_cents / 100
  431. )
  432. .should.equal(true)
  433. })
  434. it('should return the subscription change preview with non-legacy add-on price', async function () {
  435. this.recurlySubscription.createdAt = '2030-01-01T00:00:00Z'
  436. preview =
  437. await this.Handler.promises.previewAddSeatsSubscriptionChange(
  438. this.adminUser_id,
  439. this.adding
  440. )
  441. this.recurlySubscription.getRequestForAddOnPurchase
  442. .calledWithExactly(
  443. this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
  444. this.adding,
  445. undefined
  446. )
  447. .should.equal(true)
  448. })
  449. })
  450. })
  451. })
  452. describe('ensureFlexibleLicensingEnabled', function () {
  453. it('should throw if the subscription can not use flexible licensing', async function () {
  454. await expect(
  455. this.Handler.promises.ensureFlexibleLicensingEnabled({
  456. canUseFlexibleLicensing: false,
  457. })
  458. ).to.be.rejectedWith('The group plan does not support flexible licensing')
  459. })
  460. it('should not throw if the subscription can use flexible licensing', async function () {
  461. await expect(
  462. this.Handler.promises.ensureFlexibleLicensingEnabled({
  463. canUseFlexibleLicensing: true,
  464. })
  465. ).to.not.be.rejected
  466. })
  467. })
  468. describe('upgradeGroupPlan', function () {
  469. it('should upgrade the subscription for flexible licensing group plans', async function () {
  470. this.SubscriptionLocator.promises.getUsersSubscription = sinon
  471. .stub()
  472. .resolves({ groupPlan: true, planCode: 'group_collaborator' })
  473. await this.Handler.promises.upgradeGroupPlan(this.user_id)
  474. this.recurlySubscription.getRequestForGroupPlanUpgrade
  475. .calledWith('group_professional')
  476. .should.equal(true)
  477. this.RecurlyClient.promises.applySubscriptionChangeRequest
  478. .calledWith(this.changeRequest)
  479. .should.equal(true)
  480. this.SubscriptionHandler.promises.syncSubscription
  481. .calledWith({ uuid: this.changeRequest.subscription.id }, this.user_id)
  482. .should.equal(true)
  483. })
  484. it('should upgrade the subscription for legacy group plans', async function () {
  485. this.SubscriptionLocator.promises.getUsersSubscription = sinon
  486. .stub()
  487. .resolves({
  488. groupPlan: true,
  489. planCode: 'group_collaborator_10_educational',
  490. })
  491. await this.Handler.promises.upgradeGroupPlan(this.user_id)
  492. this.recurlySubscription.getRequestForGroupPlanUpgrade
  493. .calledWith('group_professional_10_educational')
  494. .should.equal(true)
  495. this.RecurlyClient.promises.applySubscriptionChangeRequest
  496. .calledWith(this.changeRequest)
  497. .should.equal(true)
  498. this.SubscriptionHandler.promises.syncSubscription
  499. .calledWith({ uuid: this.changeRequest.subscription.id }, this.user_id)
  500. .should.equal(true)
  501. })
  502. it('should fail the upgrade if is professional already', async function () {
  503. this.SubscriptionLocator.promises.getUsersSubscription = sinon
  504. .stub()
  505. .resolves({ groupPlan: true, planCode: 'group_professional' })
  506. await expect(
  507. this.Handler.promises.upgradeGroupPlan(this.user_id)
  508. ).to.be.rejectedWith('Not eligible for group plan upgrade')
  509. })
  510. it('should fail the upgrade if not group plan', async function () {
  511. this.SubscriptionLocator.promises.getUsersSubscription = sinon
  512. .stub()
  513. .resolves({ groupPlan: false, planCode: 'test_plan_code' })
  514. await expect(
  515. this.Handler.promises.upgradeGroupPlan(this.user_id)
  516. ).to.be.rejectedWith('Not eligible for group plan upgrade')
  517. })
  518. })
  519. describe('getGroupPlanUpgradePreview', function () {
  520. it('should generate preview for subscription upgrade', async function () {
  521. this.SubscriptionLocator.promises.getUsersSubscription = sinon
  522. .stub()
  523. .resolves({ groupPlan: true, planCode: 'group_collaborator' })
  524. const result = await this.Handler.promises.getGroupPlanUpgradePreview(
  525. this.user_id
  526. )
  527. this.RecurlyClient.promises.previewSubscriptionChange
  528. .calledWith(this.changeRequest)
  529. .should.equal(true)
  530. result.should.equal(this.changePreview)
  531. })
  532. })
  533. })