SubscriptionHandler.test.mjs 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
  1. import { expect, vi } from 'vitest'
  2. import sinon from 'sinon'
  3. import PaymentProviderEntities from '../../../../app/src/Features/Subscription/PaymentProviderEntities.mjs'
  4. import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.mjs'
  5. import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.mjs'
  6. const { PaymentProviderSubscription } = PaymentProviderEntities
  7. const MODULE_PATH =
  8. '../../../../app/src/Features/Subscription/SubscriptionHandler'
  9. const mockRecurlySubscriptions = {
  10. 'subscription-123-active': {
  11. uuid: 'subscription-123-active',
  12. plan: {
  13. name: 'Collaborator',
  14. plan_code: 'collaborator',
  15. },
  16. current_period_ends_at: new Date(),
  17. state: 'active',
  18. unit_amount_in_cents: 999,
  19. account: {
  20. account_code: 'user-123',
  21. },
  22. },
  23. }
  24. const mockRecurlyClientSubscriptions = {
  25. 'subscription-123-active': new PaymentProviderSubscription({
  26. id: 'subscription-123-active',
  27. userId: 'user-id',
  28. planCode: 'collaborator',
  29. planName: 'Collaborator',
  30. planPrice: 10,
  31. subtotal: 10,
  32. currency: 'USD',
  33. total: 10,
  34. }),
  35. }
  36. const mockSubscriptionChanges = {
  37. 'subscription-123-active': {
  38. id: 'subscription-change-id',
  39. subscriptionId: 'subscription-123-recurly-id', // not the UUID
  40. },
  41. }
  42. describe('SubscriptionHandler', function () {
  43. beforeEach(async function (ctx) {
  44. ctx.Settings = {
  45. plans: [
  46. {
  47. planCode: 'collaborator',
  48. name: 'Collaborator',
  49. price_in_cents: 1000,
  50. features: {
  51. collaborators: -1,
  52. versioning: true,
  53. },
  54. },
  55. {
  56. planCode: 'professional',
  57. price_in_cents: 1500,
  58. },
  59. ],
  60. defaultPlanCode: {
  61. collaborators: 0,
  62. versioning: false,
  63. },
  64. }
  65. ctx.activeRecurlySubscription =
  66. mockRecurlySubscriptions['subscription-123-active']
  67. ctx.activeRecurlyClientSubscription =
  68. mockRecurlyClientSubscriptions['subscription-123-active']
  69. ctx.activeRecurlySubscriptionChange =
  70. mockSubscriptionChanges['subscription-123-active']
  71. ctx.User = {}
  72. ctx.user = { _id: (ctx.user_id = 'user_id_here_') }
  73. ctx.subscription = {
  74. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  75. }
  76. ctx.RecurlyWrapper = {
  77. promises: {
  78. getSubscription: sinon.stub().resolves(ctx.activeRecurlySubscription),
  79. redeemCoupon: sinon.stub().resolves(),
  80. createSubscription: sinon
  81. .stub()
  82. .resolves(ctx.activeRecurlySubscription),
  83. getBillingInfo: sinon.stub().resolves(),
  84. getAccountPastDueInvoices: sinon.stub().resolves(),
  85. attemptInvoiceCollection: sinon.stub().resolves(),
  86. listAccountActiveSubscriptions: sinon.stub().resolves([]),
  87. },
  88. }
  89. ctx.RecurlyClient = {
  90. promises: {
  91. reactivateSubscriptionByUuid: sinon
  92. .stub()
  93. .resolves(ctx.activeRecurlyClientSubscription),
  94. cancelSubscriptionByUuid: sinon.stub().resolves(),
  95. applySubscriptionChangeRequest: sinon
  96. .stub()
  97. .resolves(ctx.activeRecurlySubscriptionChange),
  98. getSubscription: sinon
  99. .stub()
  100. .resolves(ctx.activeRecurlyClientSubscription),
  101. pauseSubscriptionByUuid: sinon.stub().resolves(),
  102. resumeSubscriptionByUuid: sinon.stub().resolves(),
  103. },
  104. }
  105. ctx.SubscriptionUpdater = {
  106. promises: {
  107. updateSubscriptionFromRecurly: sinon.stub().resolves(),
  108. syncSubscription: sinon.stub().resolves(),
  109. syncStripeSubscription: sinon.stub().resolves(),
  110. startFreeTrial: sinon.stub().resolves(),
  111. },
  112. }
  113. ctx.LimitationsManager = {
  114. promises: {
  115. userHasSubscription: sinon.stub().resolves(),
  116. },
  117. }
  118. ctx.SubscriptionLocator = {
  119. promises: {
  120. getUsersSubscription: sinon.stub().resolves(ctx.subscription),
  121. },
  122. }
  123. ctx.EmailHandler = {
  124. sendEmail: sinon.stub(),
  125. sendDeferredEmail: sinon.stub(),
  126. }
  127. ctx.UserUpdater = {
  128. promises: {
  129. updateUser: sinon.stub().resolves(),
  130. },
  131. }
  132. ctx.SplitTestHandler = {
  133. promises: {
  134. getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
  135. },
  136. }
  137. vi.doMock(
  138. '../../../../app/src/Features/Subscription/RecurlyWrapper',
  139. () => ({
  140. default: ctx.RecurlyWrapper,
  141. })
  142. )
  143. vi.doMock(
  144. '../../../../app/src/Features/Subscription/RecurlyClient',
  145. () => ({
  146. default: ctx.RecurlyClient,
  147. })
  148. )
  149. vi.doMock('@overleaf/settings', () => ({
  150. default: ctx.Settings,
  151. }))
  152. vi.doMock('../../../../app/src/models/User', () => ({
  153. User: ctx.User,
  154. }))
  155. vi.doMock(
  156. '../../../../app/src/Features/Subscription/SubscriptionHelper',
  157. () => ({
  158. default: SubscriptionHelper,
  159. })
  160. )
  161. vi.doMock(
  162. '../../../../app/src/Features/Subscription/SubscriptionUpdater',
  163. () => ({
  164. default: ctx.SubscriptionUpdater,
  165. })
  166. )
  167. vi.doMock(
  168. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  169. () => ({
  170. default: ctx.SubscriptionLocator,
  171. })
  172. )
  173. vi.doMock(
  174. '../../../../app/src/Features/Subscription/LimitationsManager',
  175. () => ({
  176. default: ctx.LimitationsManager,
  177. })
  178. )
  179. vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
  180. default: ctx.EmailHandler,
  181. }))
  182. vi.doMock(
  183. '../../../../app/src/Features/Analytics/AnalyticsManager',
  184. () => ({
  185. default: ctx.AnalyticsManager,
  186. })
  187. )
  188. vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
  189. default: ctx.UserUpdater,
  190. }))
  191. vi.doMock(
  192. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  193. () => ({
  194. default: ctx.SplitTestHandler,
  195. })
  196. )
  197. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  198. default: (ctx.Modules = {
  199. promises: {
  200. hooks: {
  201. fire: sinon.stub(),
  202. },
  203. },
  204. }),
  205. }))
  206. ctx.SubscriptionHandler = (await import(MODULE_PATH)).default
  207. })
  208. describe('createSubscription', function () {
  209. beforeEach(function (ctx) {
  210. ctx.subscriptionDetails = {
  211. cvv: '123',
  212. number: '12345',
  213. }
  214. ctx.recurlyTokenIds = { billing: '45555666' }
  215. })
  216. describe('successfully', function () {
  217. beforeEach(async function (ctx) {
  218. await ctx.SubscriptionHandler.promises.createSubscription(
  219. ctx.user,
  220. ctx.subscriptionDetails,
  221. ctx.recurlyTokenIds
  222. )
  223. })
  224. it('should create the subscription with the wrapper', function (ctx) {
  225. ctx.RecurlyWrapper.promises.createSubscription
  226. .calledWith(ctx.user, ctx.subscriptionDetails, ctx.recurlyTokenIds)
  227. .should.equal(true)
  228. })
  229. it('should sync the subscription to the user', function (ctx) {
  230. ctx.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  231. true
  232. )
  233. ctx.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  234. ctx.activeRecurlySubscription
  235. )
  236. ctx.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  237. ctx.user._id
  238. )
  239. })
  240. it('should not set last trial date if not a trial/the trial_started_at is not set', function (ctx) {
  241. ctx.UserUpdater.promises.updateUser.should.not.have.been.called
  242. })
  243. })
  244. describe('when the subscription is a trial and has a trial_started_at date', function () {
  245. beforeEach(async function (ctx) {
  246. ctx.activeRecurlySubscription.trial_started_at =
  247. '2024-01-01T09:58:35.531+00:00'
  248. await ctx.SubscriptionHandler.promises.createSubscription(
  249. ctx.user,
  250. ctx.subscriptionDetails,
  251. ctx.recurlyTokenIds
  252. )
  253. })
  254. it('should set the users lastTrial date', function (ctx) {
  255. ctx.UserUpdater.promises.updateUser.should.have.been.calledOnce
  256. expect(ctx.UserUpdater.promises.updateUser.args[0][0]).to.deep.equal({
  257. _id: ctx.user_id,
  258. lastTrial: {
  259. $not: {
  260. $gt: new Date(ctx.activeRecurlySubscription.trial_started_at),
  261. },
  262. },
  263. })
  264. expect(ctx.UserUpdater.promises.updateUser.args[0][1]).to.deep.equal({
  265. $set: {
  266. lastTrial: new Date(ctx.activeRecurlySubscription.trial_started_at),
  267. },
  268. })
  269. })
  270. })
  271. describe('when there is already a subscription in Recurly', function () {
  272. beforeEach(function (ctx) {
  273. ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions.resolves([
  274. ctx.subscription,
  275. ])
  276. })
  277. it('should an error', function (ctx) {
  278. expect(
  279. ctx.SubscriptionHandler.promises.createSubscription(
  280. ctx.user,
  281. ctx.subscriptionDetails,
  282. ctx.recurlyTokenIds
  283. )
  284. ).to.be.rejectedWith('user already has subscription in recurly')
  285. })
  286. })
  287. })
  288. describe('updateSubscription', function () {
  289. beforeEach(function (ctx) {
  290. ctx.user.id = ctx.activeRecurlySubscription.account.account_code
  291. ctx.User.findById = (userId, projection) => ({
  292. exec: () => {
  293. userId.should.equal(ctx.user.id)
  294. return Promise.resolve(ctx.user)
  295. },
  296. })
  297. })
  298. it('should not fire updatePaidSubscription hook if user has no subscription', async function (ctx) {
  299. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  300. hasSubscription: false,
  301. subscription: null,
  302. })
  303. await ctx.SubscriptionHandler.promises.updateSubscription(
  304. ctx.user,
  305. ctx.plan_code
  306. )
  307. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  308. 'updatePaidSubscription',
  309. sinon.match.any,
  310. sinon.match.any,
  311. sinon.match.any
  312. )
  313. })
  314. it('should not fire updatePaidSubscription hook if user has custom subscription', async function (ctx) {
  315. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  316. hasSubscription: true,
  317. subscription: { customAccount: true },
  318. })
  319. await ctx.SubscriptionHandler.promises.updateSubscription(
  320. ctx.user,
  321. ctx.plan_code
  322. )
  323. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  324. 'updatePaidSubscription',
  325. sinon.match.any,
  326. sinon.match.any,
  327. sinon.match.any
  328. )
  329. })
  330. it('should fire updatePaidSubscription to update a valid subscription', async function (ctx) {
  331. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  332. hasSubscription: true,
  333. subscription: ctx.subscription,
  334. })
  335. await ctx.SubscriptionHandler.promises.updateSubscription(
  336. ctx.user,
  337. ctx.plan_code
  338. )
  339. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  340. 'updatePaidSubscription',
  341. ctx.subscription,
  342. ctx.plan_code,
  343. ctx.user._id
  344. )
  345. })
  346. })
  347. describe('cancelPendingSubscriptionChange', function () {
  348. beforeEach(function (ctx) {
  349. ctx.user.id = ctx.activeRecurlySubscription.account.account_code
  350. ctx.User.findById = (userId, projection) => ({
  351. exec: () => {
  352. userId.should.equal(ctx.user.id)
  353. return Promise.resolve(ctx.user)
  354. },
  355. })
  356. })
  357. it('should not fire hooks if user has no subscription', async function (ctx) {
  358. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  359. hasSubscription: false,
  360. subscription: null,
  361. })
  362. await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
  363. ctx.user,
  364. ctx.plan_code
  365. )
  366. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.called
  367. })
  368. it('should get payment record and apply change request', async function (ctx) {
  369. const changeRequest = { subscription: { id: 'sub_123' } }
  370. const paymentProviderSubscription = {
  371. id: 'sub_123',
  372. service: 'stripe',
  373. getRequestForPlanChangeCancellation: sinon
  374. .stub()
  375. .returns(changeRequest),
  376. }
  377. const paymentRecord = { subscription: paymentProviderSubscription }
  378. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  379. hasSubscription: true,
  380. subscription: ctx.subscription,
  381. })
  382. ctx.Modules.promises.hooks.fire
  383. .withArgs('getPaymentFromRecord', ctx.subscription)
  384. .resolves([paymentRecord])
  385. ctx.Modules.promises.hooks.fire
  386. .withArgs(
  387. 'applySubscriptionChangeRequestAndSync',
  388. changeRequest,
  389. ctx.user._id.toString()
  390. )
  391. .resolves([Promise.resolve()])
  392. await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
  393. ctx.user,
  394. ctx.plan_code
  395. )
  396. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  397. 'getPaymentFromRecord',
  398. ctx.subscription
  399. )
  400. expect(paymentProviderSubscription.getRequestForPlanChangeCancellation).to
  401. .have.been.called
  402. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  403. 'applySubscriptionChangeRequestAndSync',
  404. changeRequest,
  405. ctx.user._id.toString()
  406. )
  407. })
  408. it('should remove pending change when there are no add-on changes to preserve', async function (ctx) {
  409. const paymentProviderSubscription = {
  410. id: 'sub_123',
  411. service: 'stripe',
  412. pendingChange: { nextPlanCode: 'student' },
  413. getRequestForPlanChangeCancellation: sinon.stub().returns(null),
  414. }
  415. const paymentRecord = { subscription: paymentProviderSubscription }
  416. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  417. hasSubscription: true,
  418. subscription: ctx.subscription,
  419. })
  420. ctx.Modules.promises.hooks.fire
  421. .withArgs('getPaymentFromRecord', ctx.subscription)
  422. .resolves([paymentRecord])
  423. ctx.Modules.promises.hooks.fire
  424. .withArgs('cancelPendingPaidSubscriptionChange', ctx.subscription)
  425. .resolves([Promise.resolve()])
  426. await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
  427. ctx.user,
  428. ctx.plan_code
  429. )
  430. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  431. 'getPaymentFromRecord',
  432. ctx.subscription
  433. )
  434. expect(paymentProviderSubscription.getRequestForPlanChangeCancellation).to
  435. .have.been.called
  436. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  437. 'cancelPendingPaidSubscriptionChange',
  438. ctx.subscription
  439. )
  440. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  441. 'applySubscriptionChangeRequestAndSync'
  442. )
  443. })
  444. })
  445. describe('removeAddon', function () {
  446. beforeEach(function (ctx) {
  447. ctx.addOnCode = AI_ADD_ON_CODE
  448. })
  449. describe('when split test is disabled', function () {
  450. beforeEach(async function (ctx) {
  451. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  452. variant: 'control',
  453. })
  454. await ctx.SubscriptionHandler.promises.removeAddon(
  455. ctx.user,
  456. ctx.addOnCode
  457. )
  458. })
  459. it('should remove the addon', function (ctx) {
  460. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  461. 'removeAddOn',
  462. ctx.user._id,
  463. ctx.addOnCode
  464. )
  465. })
  466. it('should send the email after 1 hour', function (ctx) {
  467. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  468. expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  469. 'canceledSubscriptionOrAddOn',
  470. { to: ctx.user.email, first_name: ctx.user.first_name },
  471. ONE_HOUR_IN_MS
  472. )
  473. })
  474. })
  475. describe('when split test is enabled', function () {
  476. beforeEach(async function (ctx) {
  477. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  478. variant: 'enabled',
  479. })
  480. await ctx.SubscriptionHandler.promises.removeAddon(
  481. ctx.user,
  482. ctx.addOnCode
  483. )
  484. })
  485. it('should remove the addon', function (ctx) {
  486. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  487. 'removeAddOn',
  488. ctx.user._id,
  489. ctx.addOnCode
  490. )
  491. })
  492. it('should send the email after 1 hour', function (ctx) {
  493. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  494. expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  495. 'canceledSubscriptionOrAddOn',
  496. { to: ctx.user.email, first_name: ctx.user.first_name },
  497. ONE_HOUR_IN_MS
  498. )
  499. })
  500. })
  501. describe('when addon is not AI assistant', function () {
  502. beforeEach(async function (ctx) {
  503. ctx.addOnCode = 'other-addon'
  504. await ctx.SubscriptionHandler.promises.removeAddon(
  505. ctx.user,
  506. ctx.addOnCode
  507. )
  508. })
  509. it('should remove the addon', function (ctx) {
  510. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  511. 'removeAddOn',
  512. ctx.user._id,
  513. ctx.addOnCode
  514. )
  515. })
  516. it('should not send an email', function (ctx) {
  517. expect(ctx.EmailHandler.sendDeferredEmail).to.not.have.been.called
  518. })
  519. })
  520. })
  521. describe('cancelSubscription', function () {
  522. describe('with a user without a subscription', function () {
  523. beforeEach(async function (ctx) {
  524. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  525. hasSubscription: false,
  526. subscription: ctx.subscription,
  527. })
  528. await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user)
  529. })
  530. it('should redirect to the subscription dashboard', function (ctx) {
  531. ctx.RecurlyClient.promises.cancelSubscriptionByUuid.called.should.equal(
  532. false
  533. )
  534. })
  535. })
  536. describe('with a user with a subscription', function () {
  537. beforeEach(async function (ctx) {
  538. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  539. hasSubscription: true,
  540. subscription: ctx.subscription,
  541. })
  542. })
  543. describe('when split test is disabled', function () {
  544. beforeEach(async function (ctx) {
  545. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  546. variant: 'control',
  547. })
  548. await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user)
  549. })
  550. it('should cancel the subscription', function (ctx) {
  551. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  552. 'cancelPaidSubscription',
  553. ctx.subscription
  554. )
  555. })
  556. it('should send the email after 1 hour', function (ctx) {
  557. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  558. expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  559. 'canceledSubscriptionOrAddOn',
  560. { to: ctx.user.email, first_name: ctx.user.first_name },
  561. ONE_HOUR_IN_MS
  562. )
  563. })
  564. })
  565. describe('when split test is enabled', function () {
  566. beforeEach(async function (ctx) {
  567. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  568. variant: 'enabled',
  569. })
  570. await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user)
  571. })
  572. it('should cancel the subscription', function (ctx) {
  573. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  574. 'cancelPaidSubscription',
  575. ctx.subscription
  576. )
  577. })
  578. it('should send the email after 1 hour', function (ctx) {
  579. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  580. expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  581. 'canceledSubscriptionOrAddOn',
  582. { to: ctx.user.email, first_name: ctx.user.first_name },
  583. ONE_HOUR_IN_MS
  584. )
  585. })
  586. })
  587. })
  588. })
  589. describe('resumeSubscription', function () {
  590. describe('for a user without a subscription', function () {
  591. beforeEach(async function (ctx) {
  592. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  593. hasSubscription: false,
  594. subscription: ctx.subscription,
  595. })
  596. })
  597. it('should not make a resume call to recurly', async function (ctx) {
  598. expect(
  599. ctx.SubscriptionHandler.promises.resumeSubscription(ctx.user)
  600. ).to.be.rejectedWith('No active subscription to resume')
  601. ctx.RecurlyClient.promises.resumeSubscriptionByUuid.called.should.equal(
  602. false
  603. )
  604. })
  605. })
  606. describe('for a user with a subscription', function () {
  607. beforeEach(async function (ctx) {
  608. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  609. hasSubscription: true,
  610. subscription: {
  611. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  612. recurlyStatus: { state: 'non-trial' },
  613. planCode: 'collaborator',
  614. },
  615. })
  616. })
  617. it('should call resume hook', async function (ctx) {
  618. await ctx.SubscriptionHandler.promises.resumeSubscription(ctx.user)
  619. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  620. 'resumePaidSubscription',
  621. {
  622. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  623. recurlyStatus: { state: 'non-trial' },
  624. planCode: 'collaborator',
  625. }
  626. )
  627. })
  628. })
  629. })
  630. describe('pauseSubscription', function () {
  631. describe('for a user without a subscription', function () {
  632. beforeEach(async function (ctx) {
  633. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  634. hasSubscription: false,
  635. subscription: ctx.subscription,
  636. })
  637. })
  638. it('should not make a pause call to recurly', async function (ctx) {
  639. expect(
  640. ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  641. ).to.be.rejectedWith('No active subscription to pause')
  642. ctx.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  643. false
  644. )
  645. })
  646. })
  647. describe('for a user with an annual subscription', function () {
  648. beforeEach(async function (ctx) {
  649. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  650. hasSubscription: false,
  651. subscription: {
  652. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  653. recurlyStatus: { state: 'non-trial' },
  654. planCode: 'collaborator-annual',
  655. },
  656. })
  657. })
  658. it('should not make a pause call to recurly', async function (ctx) {
  659. expect(
  660. ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  661. ).to.be.rejectedWith('Can only pause monthly individual plans')
  662. ctx.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  663. false
  664. )
  665. })
  666. })
  667. describe('for a user with a subscription', function () {
  668. beforeEach(async function (ctx) {
  669. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  670. hasSubscription: true,
  671. subscription: {
  672. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  673. recurlyStatus: { state: 'non-trial' },
  674. planCode: 'collaborator',
  675. addOns: [],
  676. },
  677. })
  678. })
  679. it('should call pause hook', async function (ctx) {
  680. await ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  681. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  682. 'pausePaidSubscription',
  683. {
  684. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  685. recurlyStatus: { state: 'non-trial' },
  686. planCode: 'collaborator',
  687. addOns: [],
  688. },
  689. 3
  690. )
  691. })
  692. })
  693. describe('for a user in a trial', function () {
  694. beforeEach(async function (ctx) {
  695. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  696. hasSubscription: true,
  697. subscription: {
  698. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  699. recurlyStatus: {
  700. state: 'trial',
  701. trialEndsAt: Date.now() + 1000000,
  702. },
  703. planCode: 'collaborator',
  704. },
  705. })
  706. })
  707. it('should not make a pause call to recurly', async function (ctx) {
  708. expect(
  709. ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  710. ).to.be.rejectedWith('Cannot pause a subscription in a trial')
  711. ctx.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  712. false
  713. )
  714. })
  715. })
  716. describe('for a user with addons', function () {
  717. beforeEach(async function (ctx) {
  718. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  719. hasSubscription: true,
  720. subscription: {
  721. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  722. recurlyStatus: { state: 'non-trial' },
  723. planCode: 'collaborator',
  724. addOns: ['mock-addon'],
  725. },
  726. })
  727. })
  728. it('should not make a pause call to recurly', async function (ctx) {
  729. expect(
  730. ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  731. ).to.be.rejectedWith('Cannot pause a subscription with addons')
  732. ctx.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  733. false
  734. )
  735. })
  736. })
  737. })
  738. describe('reactivateSubscription', function () {
  739. describe('with a user without a subscription', function () {
  740. beforeEach(async function (ctx) {
  741. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  742. hasSubscription: false,
  743. subscription: ctx.subscription,
  744. })
  745. await ctx.SubscriptionHandler.promises.reactivateSubscription(ctx.user)
  746. })
  747. it('should redirect to the subscription dashboard', function (ctx) {
  748. ctx.RecurlyClient.promises.reactivateSubscriptionByUuid.called.should.equal(
  749. false
  750. )
  751. })
  752. it('should not send a notification email', function (ctx) {
  753. sinon.assert.notCalled(ctx.EmailHandler.sendEmail)
  754. })
  755. })
  756. describe('with a user with a subscription', function () {
  757. beforeEach(async function (ctx) {
  758. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  759. hasSubscription: true,
  760. subscription: ctx.subscription,
  761. })
  762. await ctx.SubscriptionHandler.promises.reactivateSubscription(ctx.user)
  763. })
  764. it('should reactivate the subscription', function (ctx) {
  765. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  766. 'reactivatePaidSubscription',
  767. ctx.subscription
  768. )
  769. })
  770. it('should send a notification email', function (ctx) {
  771. sinon.assert.calledWith(
  772. ctx.EmailHandler.sendEmail,
  773. 'reactivatedSubscription'
  774. )
  775. })
  776. })
  777. })
  778. describe('syncSubscription', function () {
  779. describe('with an actionable request', function () {
  780. beforeEach(async function (ctx) {
  781. ctx.user.id = ctx.activeRecurlySubscription.account.account_code
  782. ctx.User.findById = (userId, projection) => ({
  783. exec: () => {
  784. userId.should.equal(ctx.user.id)
  785. return Promise.resolve(ctx.user)
  786. },
  787. })
  788. await ctx.SubscriptionHandler.promises.syncSubscription(
  789. ctx.activeRecurlySubscription,
  790. {}
  791. )
  792. })
  793. it('should request the affected subscription from the API', function (ctx) {
  794. ctx.RecurlyWrapper.promises.getSubscription
  795. .calledWith(ctx.activeRecurlySubscription.uuid)
  796. .should.equal(true)
  797. })
  798. it('should request the account details of the subscription', function (ctx) {
  799. const options = ctx.RecurlyWrapper.promises.getSubscription.args[0][1]
  800. options.includeAccount.should.equal(true)
  801. })
  802. it('should sync the subscription to the user', function (ctx) {
  803. ctx.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  804. true
  805. )
  806. ctx.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  807. ctx.activeRecurlySubscription
  808. )
  809. ctx.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  810. ctx.user._id
  811. )
  812. })
  813. })
  814. })
  815. describe('attemptPaypalInvoiceCollection', function () {
  816. describe('for credit card users', function () {
  817. beforeEach(async function (ctx) {
  818. ctx.RecurlyWrapper.promises.getBillingInfo.resolves({
  819. paypal_billing_agreement_id: null,
  820. })
  821. await ctx.SubscriptionHandler.promises.attemptPaypalInvoiceCollection(
  822. ctx.activeRecurlySubscription.account.account_code
  823. )
  824. })
  825. it('gets billing infos', function (ctx) {
  826. sinon.assert.calledWith(
  827. ctx.RecurlyWrapper.promises.getBillingInfo,
  828. ctx.activeRecurlySubscription.account.account_code
  829. )
  830. })
  831. it('skips user', function (ctx) {
  832. sinon.assert.notCalled(
  833. ctx.RecurlyWrapper.promises.getAccountPastDueInvoices
  834. )
  835. })
  836. })
  837. describe('for paypal users', function () {
  838. beforeEach(async function (ctx) {
  839. ctx.RecurlyWrapper.promises.getBillingInfo.resolves({
  840. paypal_billing_agreement_id: 'mock-billing-agreement',
  841. })
  842. ctx.RecurlyWrapper.promises.getAccountPastDueInvoices.resolves([
  843. { invoice_number: 'mock-invoice-number' },
  844. ])
  845. await ctx.SubscriptionHandler.promises.attemptPaypalInvoiceCollection(
  846. ctx.activeRecurlySubscription.account.account_code
  847. )
  848. })
  849. it('gets past due invoices', function (ctx) {
  850. sinon.assert.calledWith(
  851. ctx.RecurlyWrapper.promises.getAccountPastDueInvoices,
  852. ctx.activeRecurlySubscription.account.account_code
  853. )
  854. })
  855. it('calls attemptInvoiceCollection', function (ctx) {
  856. sinon.assert.calledWith(
  857. ctx.RecurlyWrapper.promises.attemptInvoiceCollection,
  858. 'mock-invoice-number'
  859. )
  860. })
  861. })
  862. })
  863. describe('validateNoSubscriptionInRecurly', function () {
  864. describe('with a subscription in recurly', function () {
  865. beforeEach(async function (ctx) {
  866. ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions.resolves([
  867. ctx.subscription,
  868. ])
  869. ctx.isValid =
  870. await ctx.SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
  871. ctx.user_id
  872. )
  873. })
  874. it('should call RecurlyWrapper.promises.listAccountActiveSubscriptions with the user id', function (ctx) {
  875. ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions
  876. .calledWith(ctx.user_id)
  877. .should.equal(true)
  878. })
  879. it('should sync the subscription', function (ctx) {
  880. ctx.SubscriptionUpdater.promises.syncSubscription
  881. .calledWith(ctx.subscription, ctx.user_id)
  882. .should.equal(true)
  883. })
  884. it('should return false', function (ctx) {
  885. expect(ctx.isValid).to.equal(false)
  886. })
  887. })
  888. describe('with no subscription in recurly', function () {
  889. beforeEach(async function (ctx) {
  890. ctx.isValid =
  891. await ctx.SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
  892. ctx.user_id
  893. )
  894. })
  895. it('should be rejected and not sync the subscription', function (ctx) {
  896. ctx.SubscriptionUpdater.promises.syncSubscription.called.should.equal(
  897. false
  898. )
  899. })
  900. it('should return true', function (ctx) {
  901. expect(ctx.isValid).to.equal(true)
  902. })
  903. })
  904. })
  905. })