SubscriptionHandler.test.mjs 36 KB

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