SubscriptionHandler.test.mjs 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105
  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. ctx.WorkbenchRateLimiter = {
  138. resetTokenUsage: sinon.stub().resolves(),
  139. }
  140. ctx.AiFeatureUsageRateLimiter = {
  141. resetFeatureUsage: sinon.stub().resolves(),
  142. }
  143. vi.doMock(
  144. '../../../../app/src/Features/Subscription/RecurlyWrapper',
  145. () => ({
  146. default: ctx.RecurlyWrapper,
  147. })
  148. )
  149. vi.doMock(
  150. '../../../../app/src/Features/Subscription/RecurlyClient',
  151. () => ({
  152. default: ctx.RecurlyClient,
  153. })
  154. )
  155. vi.doMock('@overleaf/settings', () => ({
  156. default: ctx.Settings,
  157. }))
  158. vi.doMock('../../../../app/src/models/User', () => ({
  159. User: ctx.User,
  160. }))
  161. vi.doMock(
  162. '../../../../app/src/Features/Subscription/SubscriptionHelper',
  163. () => ({
  164. default: SubscriptionHelper,
  165. })
  166. )
  167. vi.doMock(
  168. '../../../../app/src/Features/Subscription/SubscriptionUpdater',
  169. () => ({
  170. default: ctx.SubscriptionUpdater,
  171. })
  172. )
  173. vi.doMock(
  174. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  175. () => ({
  176. default: ctx.SubscriptionLocator,
  177. })
  178. )
  179. vi.doMock(
  180. '../../../../app/src/Features/Subscription/LimitationsManager',
  181. () => ({
  182. default: ctx.LimitationsManager,
  183. })
  184. )
  185. vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
  186. default: ctx.EmailHandler,
  187. }))
  188. vi.doMock(
  189. '../../../../app/src/Features/Analytics/AnalyticsManager',
  190. () => ({
  191. default: ctx.AnalyticsManager,
  192. })
  193. )
  194. vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
  195. default: ctx.UserUpdater,
  196. }))
  197. vi.doMock(
  198. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  199. () => ({
  200. default: ctx.SplitTestHandler,
  201. })
  202. )
  203. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  204. default: (ctx.Modules = {
  205. promises: {
  206. hooks: {
  207. fire: sinon.stub(),
  208. },
  209. },
  210. }),
  211. }))
  212. vi.doMock(
  213. '../../../../app/src/infrastructure/rate-limiters/WorkbenchRateLimiter',
  214. () => ({
  215. default: ctx.WorkbenchRateLimiter,
  216. })
  217. )
  218. vi.doMock(
  219. '../../../../app/src/infrastructure/rate-limiters/AiFeatureUsageRateLimiter',
  220. () => ({
  221. default: ctx.AiFeatureUsageRateLimiter,
  222. })
  223. )
  224. ctx.SubscriptionHandler = (await import(MODULE_PATH)).default
  225. })
  226. describe('createSubscription', function () {
  227. beforeEach(function (ctx) {
  228. ctx.subscriptionDetails = {
  229. cvv: '123',
  230. number: '12345',
  231. }
  232. ctx.recurlyTokenIds = { billing: '45555666' }
  233. })
  234. describe('successfully', function () {
  235. beforeEach(async function (ctx) {
  236. await ctx.SubscriptionHandler.promises.createSubscription(
  237. ctx.user,
  238. ctx.subscriptionDetails,
  239. ctx.recurlyTokenIds
  240. )
  241. })
  242. it('should create the subscription with the wrapper', function (ctx) {
  243. ctx.RecurlyWrapper.promises.createSubscription
  244. .calledWith(ctx.user, ctx.subscriptionDetails, ctx.recurlyTokenIds)
  245. .should.equal(true)
  246. })
  247. it('should sync the subscription to the user', function (ctx) {
  248. ctx.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  249. true
  250. )
  251. ctx.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  252. ctx.activeRecurlySubscription
  253. )
  254. ctx.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  255. ctx.user._id
  256. )
  257. })
  258. it('should not set last trial date if not a trial/the trial_started_at is not set', function (ctx) {
  259. ctx.UserUpdater.promises.updateUser.should.not.have.been.called
  260. })
  261. })
  262. describe('when the subscription is a trial and has a trial_started_at date', function () {
  263. beforeEach(async function (ctx) {
  264. ctx.activeRecurlySubscription.trial_started_at =
  265. '2024-01-01T09:58:35.531+00:00'
  266. await ctx.SubscriptionHandler.promises.createSubscription(
  267. ctx.user,
  268. ctx.subscriptionDetails,
  269. ctx.recurlyTokenIds
  270. )
  271. })
  272. it('should set the users lastTrial date', function (ctx) {
  273. ctx.UserUpdater.promises.updateUser.should.have.been.calledOnce
  274. expect(ctx.UserUpdater.promises.updateUser.args[0][0]).to.deep.equal({
  275. _id: ctx.user_id,
  276. lastTrial: {
  277. $not: {
  278. $gt: new Date(ctx.activeRecurlySubscription.trial_started_at),
  279. },
  280. },
  281. })
  282. expect(ctx.UserUpdater.promises.updateUser.args[0][1]).to.deep.equal({
  283. $set: {
  284. lastTrial: new Date(ctx.activeRecurlySubscription.trial_started_at),
  285. },
  286. })
  287. })
  288. })
  289. describe('when there is already a subscription in Recurly', function () {
  290. beforeEach(function (ctx) {
  291. ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions.resolves([
  292. ctx.subscription,
  293. ])
  294. })
  295. it('should an error', function (ctx) {
  296. expect(
  297. ctx.SubscriptionHandler.promises.createSubscription(
  298. ctx.user,
  299. ctx.subscriptionDetails,
  300. ctx.recurlyTokenIds
  301. )
  302. ).to.be.rejectedWith('user already has subscription in recurly')
  303. })
  304. })
  305. })
  306. describe('updateSubscription', function () {
  307. beforeEach(function (ctx) {
  308. ctx.user.id = ctx.activeRecurlySubscription.account.account_code
  309. ctx.User.findById = (userId, projection) => ({
  310. exec: () => {
  311. userId.should.equal(ctx.user.id)
  312. return Promise.resolve(ctx.user)
  313. },
  314. })
  315. })
  316. it('should not fire updatePaidSubscription hook if user has no subscription', async function (ctx) {
  317. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  318. hasSubscription: false,
  319. subscription: null,
  320. })
  321. await ctx.SubscriptionHandler.promises.updateSubscription(
  322. ctx.user,
  323. ctx.plan_code
  324. )
  325. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  326. 'updatePaidSubscription',
  327. sinon.match.any,
  328. sinon.match.any,
  329. sinon.match.any
  330. )
  331. })
  332. it('should not fire updatePaidSubscription hook if user has custom subscription', async function (ctx) {
  333. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  334. hasSubscription: true,
  335. subscription: { customAccount: true },
  336. })
  337. await ctx.SubscriptionHandler.promises.updateSubscription(
  338. ctx.user,
  339. ctx.plan_code
  340. )
  341. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  342. 'updatePaidSubscription',
  343. sinon.match.any,
  344. sinon.match.any,
  345. sinon.match.any
  346. )
  347. })
  348. it('should fire updatePaidSubscription to update a valid subscription', async function (ctx) {
  349. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  350. hasSubscription: true,
  351. subscription: ctx.subscription,
  352. })
  353. await ctx.SubscriptionHandler.promises.updateSubscription(
  354. ctx.user,
  355. ctx.plan_code
  356. )
  357. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  358. 'updatePaidSubscription',
  359. ctx.subscription,
  360. ctx.plan_code,
  361. ctx.user._id
  362. )
  363. })
  364. it('should reset the ai rate limiter usages on a successful update', async function (ctx) {
  365. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  366. hasSubscription: true,
  367. subscription: ctx.subscription,
  368. })
  369. await ctx.SubscriptionHandler.promises.updateSubscription(
  370. ctx.user,
  371. ctx.plan_code
  372. )
  373. expect(ctx.WorkbenchRateLimiter.resetTokenUsage).to.have.been.calledWith(
  374. ctx.user._id
  375. )
  376. expect(
  377. ctx.AiFeatureUsageRateLimiter.resetFeatureUsage
  378. ).to.have.been.calledWith(ctx.user._id)
  379. })
  380. it('should not reset the ai rate limiter usages when no subscription exists', async function (ctx) {
  381. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  382. hasSubscription: false,
  383. subscription: null,
  384. })
  385. await ctx.SubscriptionHandler.promises.updateSubscription(
  386. ctx.user,
  387. ctx.plan_code
  388. )
  389. expect(ctx.WorkbenchRateLimiter.resetTokenUsage).to.not.have.been.called
  390. expect(ctx.AiFeatureUsageRateLimiter.resetFeatureUsage).to.not.have.been
  391. .called
  392. })
  393. describe('previous_plan_type customer.io attribute', function () {
  394. beforeEach(function (ctx) {
  395. ctx.subscription.planCode = 'collaborator'
  396. ctx.subscription.groupPlan = false
  397. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  398. hasSubscription: true,
  399. subscription: ctx.subscription,
  400. })
  401. ctx.Modules.promises.hooks.fire.resolves()
  402. })
  403. it('should not set previous_plan_type when the new plan code matches the current plan code', async function (ctx) {
  404. await ctx.SubscriptionHandler.promises.updateSubscription(
  405. ctx.user,
  406. 'collaborator'
  407. )
  408. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  409. 'setUserProperties',
  410. sinon.match.any,
  411. sinon.match.has('previous_plan_type')
  412. )
  413. })
  414. it('should not set previous_plan_type when the new plan code resolves to the same normalised plan type', async function (ctx) {
  415. await ctx.SubscriptionHandler.promises.updateSubscription(
  416. ctx.user,
  417. 'collaborator-annual'
  418. )
  419. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  420. 'setUserProperties',
  421. sinon.match.any,
  422. sinon.match.has('previous_plan_type')
  423. )
  424. })
  425. it('should set previous_plan_type when the new plan resolves to a different normalised plan type', async function (ctx) {
  426. await ctx.SubscriptionHandler.promises.updateSubscription(
  427. ctx.user,
  428. 'professional'
  429. )
  430. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  431. 'setUserProperties',
  432. ctx.user._id,
  433. { previous_plan_type: 'standard' }
  434. )
  435. })
  436. })
  437. })
  438. describe('cancelPendingSubscriptionChange', function () {
  439. beforeEach(function (ctx) {
  440. ctx.user.id = ctx.activeRecurlySubscription.account.account_code
  441. ctx.User.findById = (userId, projection) => ({
  442. exec: () => {
  443. userId.should.equal(ctx.user.id)
  444. return Promise.resolve(ctx.user)
  445. },
  446. })
  447. })
  448. it('should not fire hooks if user has no subscription', async function (ctx) {
  449. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  450. hasSubscription: false,
  451. subscription: null,
  452. })
  453. await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
  454. ctx.user,
  455. ctx.plan_code
  456. )
  457. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.called
  458. })
  459. it('should get payment record and apply change request', async function (ctx) {
  460. const changeRequest = { subscription: { id: 'sub_123' } }
  461. const paymentProviderSubscription = {
  462. id: 'sub_123',
  463. service: 'stripe',
  464. getRequestForPlanChangeCancellation: sinon
  465. .stub()
  466. .returns(changeRequest),
  467. }
  468. const paymentRecord = { subscription: paymentProviderSubscription }
  469. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  470. hasSubscription: true,
  471. subscription: ctx.subscription,
  472. })
  473. ctx.Modules.promises.hooks.fire
  474. .withArgs('getPaymentFromRecord', ctx.subscription)
  475. .resolves([paymentRecord])
  476. ctx.Modules.promises.hooks.fire
  477. .withArgs(
  478. 'applySubscriptionChangeRequestAndSync',
  479. changeRequest,
  480. ctx.user._id.toString()
  481. )
  482. .resolves([Promise.resolve()])
  483. await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
  484. ctx.user,
  485. ctx.plan_code
  486. )
  487. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  488. 'getPaymentFromRecord',
  489. ctx.subscription
  490. )
  491. expect(paymentProviderSubscription.getRequestForPlanChangeCancellation).to
  492. .have.been.called
  493. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  494. 'applySubscriptionChangeRequestAndSync',
  495. changeRequest,
  496. ctx.user._id.toString()
  497. )
  498. })
  499. it('should remove pending change when there are no add-on changes to preserve', async function (ctx) {
  500. const paymentProviderSubscription = {
  501. id: 'sub_123',
  502. service: 'stripe',
  503. pendingChange: { nextPlanCode: 'student' },
  504. getRequestForPlanChangeCancellation: sinon.stub().returns(null),
  505. }
  506. const paymentRecord = { subscription: paymentProviderSubscription }
  507. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  508. hasSubscription: true,
  509. subscription: ctx.subscription,
  510. })
  511. ctx.Modules.promises.hooks.fire
  512. .withArgs('getPaymentFromRecord', ctx.subscription)
  513. .resolves([paymentRecord])
  514. ctx.Modules.promises.hooks.fire
  515. .withArgs('cancelPendingPaidSubscriptionChange', ctx.subscription)
  516. .resolves([Promise.resolve()])
  517. await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
  518. ctx.user,
  519. ctx.plan_code
  520. )
  521. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  522. 'getPaymentFromRecord',
  523. ctx.subscription
  524. )
  525. expect(paymentProviderSubscription.getRequestForPlanChangeCancellation).to
  526. .have.been.called
  527. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  528. 'cancelPendingPaidSubscriptionChange',
  529. ctx.subscription
  530. )
  531. expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  532. 'applySubscriptionChangeRequestAndSync'
  533. )
  534. })
  535. })
  536. describe('removeAddon', function () {
  537. beforeEach(function (ctx) {
  538. ctx.addOnCode = AI_ADD_ON_CODE
  539. })
  540. describe('when split test is disabled', function () {
  541. beforeEach(async function (ctx) {
  542. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  543. variant: 'control',
  544. })
  545. await ctx.SubscriptionHandler.promises.removeAddon(
  546. ctx.user,
  547. ctx.addOnCode
  548. )
  549. })
  550. it('should remove the addon', function (ctx) {
  551. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  552. 'removeAddOn',
  553. ctx.user._id,
  554. ctx.addOnCode
  555. )
  556. })
  557. it('should send the email after 1 hour', function (ctx) {
  558. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  559. expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  560. 'canceledSubscriptionOrAddOn',
  561. { to: ctx.user.email, first_name: ctx.user.first_name },
  562. ONE_HOUR_IN_MS
  563. )
  564. })
  565. })
  566. describe('when split test is enabled', function () {
  567. beforeEach(async function (ctx) {
  568. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  569. variant: 'enabled',
  570. })
  571. await ctx.SubscriptionHandler.promises.removeAddon(
  572. ctx.user,
  573. ctx.addOnCode
  574. )
  575. })
  576. it('should remove the addon', function (ctx) {
  577. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  578. 'removeAddOn',
  579. ctx.user._id,
  580. ctx.addOnCode
  581. )
  582. })
  583. it('should send the email after 1 hour', function (ctx) {
  584. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  585. expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  586. 'canceledSubscriptionOrAddOn',
  587. { to: ctx.user.email, first_name: ctx.user.first_name },
  588. ONE_HOUR_IN_MS
  589. )
  590. })
  591. })
  592. describe('when addon is not AI assistant', function () {
  593. beforeEach(async function (ctx) {
  594. ctx.addOnCode = 'other-addon'
  595. await ctx.SubscriptionHandler.promises.removeAddon(
  596. ctx.user,
  597. ctx.addOnCode
  598. )
  599. })
  600. it('should remove the addon', function (ctx) {
  601. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  602. 'removeAddOn',
  603. ctx.user._id,
  604. ctx.addOnCode
  605. )
  606. })
  607. it('should not send an email', function (ctx) {
  608. expect(ctx.EmailHandler.sendDeferredEmail).to.not.have.been.called
  609. })
  610. })
  611. })
  612. describe('cancelSubscription', function () {
  613. describe('with a user without a subscription', function () {
  614. beforeEach(async function (ctx) {
  615. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  616. hasSubscription: false,
  617. subscription: ctx.subscription,
  618. })
  619. await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user)
  620. })
  621. it('should redirect to the subscription dashboard', function (ctx) {
  622. ctx.RecurlyClient.promises.cancelSubscriptionByUuid.called.should.equal(
  623. false
  624. )
  625. })
  626. })
  627. describe('with a user with a subscription', function () {
  628. beforeEach(async function (ctx) {
  629. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  630. hasSubscription: true,
  631. subscription: ctx.subscription,
  632. })
  633. })
  634. describe('when split test is disabled', function () {
  635. beforeEach(async function (ctx) {
  636. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  637. variant: 'control',
  638. })
  639. await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user)
  640. })
  641. it('should cancel the subscription', function (ctx) {
  642. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  643. 'cancelPaidSubscription',
  644. ctx.subscription
  645. )
  646. })
  647. it('should send the email after 1 hour', function (ctx) {
  648. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  649. expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  650. 'canceledSubscriptionOrAddOn',
  651. { to: ctx.user.email, first_name: ctx.user.first_name },
  652. ONE_HOUR_IN_MS
  653. )
  654. })
  655. })
  656. describe('when split test is enabled', function () {
  657. beforeEach(async function (ctx) {
  658. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  659. variant: 'enabled',
  660. })
  661. await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user)
  662. })
  663. it('should cancel the subscription', function (ctx) {
  664. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  665. 'cancelPaidSubscription',
  666. ctx.subscription
  667. )
  668. })
  669. it('should send the email after 1 hour', function (ctx) {
  670. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  671. expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  672. 'canceledSubscriptionOrAddOn',
  673. { to: ctx.user.email, first_name: ctx.user.first_name },
  674. ONE_HOUR_IN_MS
  675. )
  676. })
  677. })
  678. })
  679. })
  680. describe('resumeSubscription', function () {
  681. describe('for a user without a subscription', function () {
  682. beforeEach(async function (ctx) {
  683. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  684. hasSubscription: false,
  685. subscription: ctx.subscription,
  686. })
  687. })
  688. it('should not make a resume call to recurly', async function (ctx) {
  689. expect(
  690. ctx.SubscriptionHandler.promises.resumeSubscription(ctx.user)
  691. ).to.be.rejectedWith('No active subscription to resume')
  692. ctx.RecurlyClient.promises.resumeSubscriptionByUuid.called.should.equal(
  693. false
  694. )
  695. })
  696. })
  697. describe('for a user with a subscription', function () {
  698. beforeEach(async function (ctx) {
  699. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  700. hasSubscription: true,
  701. subscription: {
  702. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  703. recurlyStatus: { state: 'non-trial' },
  704. planCode: 'collaborator',
  705. },
  706. })
  707. })
  708. it('should call resume hook', async function (ctx) {
  709. await ctx.SubscriptionHandler.promises.resumeSubscription(ctx.user)
  710. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  711. 'resumePaidSubscription',
  712. {
  713. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  714. recurlyStatus: { state: 'non-trial' },
  715. planCode: 'collaborator',
  716. }
  717. )
  718. })
  719. })
  720. })
  721. describe('pauseSubscription', function () {
  722. describe('for a user without a subscription', function () {
  723. beforeEach(async function (ctx) {
  724. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  725. hasSubscription: false,
  726. subscription: ctx.subscription,
  727. })
  728. })
  729. it('should not make a pause call to recurly', async function (ctx) {
  730. expect(
  731. ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  732. ).to.be.rejectedWith('No active subscription to pause')
  733. ctx.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  734. false
  735. )
  736. })
  737. })
  738. describe('for a user with an annual subscription', function () {
  739. beforeEach(async function (ctx) {
  740. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  741. hasSubscription: false,
  742. subscription: {
  743. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  744. recurlyStatus: { state: 'non-trial' },
  745. planCode: 'collaborator-annual',
  746. },
  747. })
  748. })
  749. it('should not make a pause call to recurly', async function (ctx) {
  750. expect(
  751. ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  752. ).to.be.rejectedWith('Can only pause monthly individual plans')
  753. ctx.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  754. false
  755. )
  756. })
  757. })
  758. describe('for a user with a subscription', function () {
  759. beforeEach(async function (ctx) {
  760. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  761. hasSubscription: true,
  762. subscription: {
  763. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  764. recurlyStatus: { state: 'non-trial' },
  765. planCode: 'collaborator',
  766. addOns: [],
  767. },
  768. })
  769. })
  770. it('should call pause hook', async function (ctx) {
  771. await ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  772. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  773. 'pausePaidSubscription',
  774. {
  775. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  776. recurlyStatus: { state: 'non-trial' },
  777. planCode: 'collaborator',
  778. addOns: [],
  779. },
  780. 3
  781. )
  782. })
  783. })
  784. describe('for a user in a trial', function () {
  785. beforeEach(async function (ctx) {
  786. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  787. hasSubscription: true,
  788. subscription: {
  789. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  790. recurlyStatus: {
  791. state: 'trial',
  792. trialEndsAt: Date.now() + 1000000,
  793. },
  794. planCode: 'collaborator',
  795. },
  796. })
  797. })
  798. it('should not make a pause call to recurly', async function (ctx) {
  799. expect(
  800. ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  801. ).to.be.rejectedWith('Cannot pause a subscription in a trial')
  802. ctx.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  803. false
  804. )
  805. })
  806. })
  807. describe('for a user with addons', function () {
  808. beforeEach(async function (ctx) {
  809. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  810. hasSubscription: true,
  811. subscription: {
  812. recurlySubscription_id: ctx.activeRecurlySubscription.uuid,
  813. recurlyStatus: { state: 'non-trial' },
  814. planCode: 'collaborator',
  815. addOns: ['mock-addon'],
  816. },
  817. })
  818. })
  819. it('should not make a pause call to recurly', async function (ctx) {
  820. expect(
  821. ctx.SubscriptionHandler.promises.pauseSubscription(ctx.user, 3)
  822. ).to.be.rejectedWith('Cannot pause a subscription with addons')
  823. ctx.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  824. false
  825. )
  826. })
  827. })
  828. })
  829. describe('reactivateSubscription', function () {
  830. describe('with a user without a subscription', function () {
  831. beforeEach(async function (ctx) {
  832. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  833. hasSubscription: false,
  834. subscription: ctx.subscription,
  835. })
  836. await ctx.SubscriptionHandler.promises.reactivateSubscription(ctx.user)
  837. })
  838. it('should redirect to the subscription dashboard', function (ctx) {
  839. ctx.RecurlyClient.promises.reactivateSubscriptionByUuid.called.should.equal(
  840. false
  841. )
  842. })
  843. it('should not send a notification email', function (ctx) {
  844. sinon.assert.notCalled(ctx.EmailHandler.sendEmail)
  845. })
  846. })
  847. describe('with a user with a subscription', function () {
  848. beforeEach(async function (ctx) {
  849. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  850. hasSubscription: true,
  851. subscription: ctx.subscription,
  852. })
  853. await ctx.SubscriptionHandler.promises.reactivateSubscription(ctx.user)
  854. })
  855. it('should reactivate the subscription', function (ctx) {
  856. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  857. 'reactivatePaidSubscription',
  858. ctx.subscription
  859. )
  860. })
  861. it('should send a notification email', function (ctx) {
  862. sinon.assert.calledWith(
  863. ctx.EmailHandler.sendEmail,
  864. 'reactivatedSubscription'
  865. )
  866. })
  867. })
  868. })
  869. describe('syncSubscription', function () {
  870. describe('with an actionable request', function () {
  871. beforeEach(async function (ctx) {
  872. ctx.user.id = ctx.activeRecurlySubscription.account.account_code
  873. ctx.User.findById = (userId, projection) => ({
  874. exec: () => {
  875. userId.should.equal(ctx.user.id)
  876. return Promise.resolve(ctx.user)
  877. },
  878. })
  879. await ctx.SubscriptionHandler.promises.syncSubscription(
  880. ctx.activeRecurlySubscription,
  881. {}
  882. )
  883. })
  884. it('should request the affected subscription from the API', function (ctx) {
  885. ctx.RecurlyWrapper.promises.getSubscription
  886. .calledWith(ctx.activeRecurlySubscription.uuid)
  887. .should.equal(true)
  888. })
  889. it('should request the account details of the subscription', function (ctx) {
  890. const options = ctx.RecurlyWrapper.promises.getSubscription.args[0][1]
  891. options.includeAccount.should.equal(true)
  892. })
  893. it('should sync the subscription to the user', function (ctx) {
  894. ctx.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  895. true
  896. )
  897. ctx.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  898. ctx.activeRecurlySubscription
  899. )
  900. ctx.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  901. ctx.user._id
  902. )
  903. })
  904. })
  905. })
  906. describe('attemptPaypalInvoiceCollection', function () {
  907. describe('for credit card users', function () {
  908. beforeEach(async function (ctx) {
  909. ctx.RecurlyWrapper.promises.getBillingInfo.resolves({
  910. paypal_billing_agreement_id: null,
  911. })
  912. await ctx.SubscriptionHandler.promises.attemptPaypalInvoiceCollection(
  913. ctx.activeRecurlySubscription.account.account_code
  914. )
  915. })
  916. it('gets billing infos', function (ctx) {
  917. sinon.assert.calledWith(
  918. ctx.RecurlyWrapper.promises.getBillingInfo,
  919. ctx.activeRecurlySubscription.account.account_code
  920. )
  921. })
  922. it('skips user', function (ctx) {
  923. sinon.assert.notCalled(
  924. ctx.RecurlyWrapper.promises.getAccountPastDueInvoices
  925. )
  926. })
  927. })
  928. describe('for paypal users', function () {
  929. beforeEach(async function (ctx) {
  930. ctx.RecurlyWrapper.promises.getBillingInfo.resolves({
  931. paypal_billing_agreement_id: 'mock-billing-agreement',
  932. })
  933. ctx.RecurlyWrapper.promises.getAccountPastDueInvoices.resolves([
  934. { invoice_number: 'mock-invoice-number' },
  935. ])
  936. await ctx.SubscriptionHandler.promises.attemptPaypalInvoiceCollection(
  937. ctx.activeRecurlySubscription.account.account_code
  938. )
  939. })
  940. it('gets past due invoices', function (ctx) {
  941. sinon.assert.calledWith(
  942. ctx.RecurlyWrapper.promises.getAccountPastDueInvoices,
  943. ctx.activeRecurlySubscription.account.account_code
  944. )
  945. })
  946. it('calls attemptInvoiceCollection', function (ctx) {
  947. sinon.assert.calledWith(
  948. ctx.RecurlyWrapper.promises.attemptInvoiceCollection,
  949. 'mock-invoice-number'
  950. )
  951. })
  952. })
  953. })
  954. describe('validateNoSubscriptionInRecurly', function () {
  955. describe('with a subscription in recurly', function () {
  956. beforeEach(async function (ctx) {
  957. ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions.resolves([
  958. ctx.subscription,
  959. ])
  960. ctx.isValid =
  961. await ctx.SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
  962. ctx.user_id
  963. )
  964. })
  965. it('should call RecurlyWrapper.promises.listAccountActiveSubscriptions with the user id', function (ctx) {
  966. ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions
  967. .calledWith(ctx.user_id)
  968. .should.equal(true)
  969. })
  970. it('should sync the subscription', function (ctx) {
  971. ctx.SubscriptionUpdater.promises.syncSubscription
  972. .calledWith(ctx.subscription, ctx.user_id)
  973. .should.equal(true)
  974. })
  975. it('should return false', function (ctx) {
  976. expect(ctx.isValid).to.equal(false)
  977. })
  978. })
  979. describe('with no subscription in recurly', function () {
  980. beforeEach(async function (ctx) {
  981. ctx.isValid =
  982. await ctx.SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
  983. ctx.user_id
  984. )
  985. })
  986. it('should be rejected and not sync the subscription', function (ctx) {
  987. ctx.SubscriptionUpdater.promises.syncSubscription.called.should.equal(
  988. false
  989. )
  990. })
  991. it('should return true', function (ctx) {
  992. expect(ctx.isValid).to.equal(true)
  993. })
  994. })
  995. })
  996. })