SubscriptionHandlerTests.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const chai = require('chai')
  4. const { expect } = chai
  5. const {
  6. RecurlySubscription,
  7. RecurlySubscriptionChangeRequest,
  8. } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
  9. const MODULE_PATH =
  10. '../../../../app/src/Features/Subscription/SubscriptionHandler'
  11. const mockRecurlySubscriptions = {
  12. 'subscription-123-active': {
  13. uuid: 'subscription-123-active',
  14. plan: {
  15. name: 'Gold',
  16. plan_code: 'gold',
  17. },
  18. current_period_ends_at: new Date(),
  19. state: 'active',
  20. unit_amount_in_cents: 999,
  21. account: {
  22. account_code: 'user-123',
  23. },
  24. },
  25. }
  26. const mockRecurlyClientSubscriptions = {
  27. 'subscription-123-active': new RecurlySubscription({
  28. id: 'subscription-123-active',
  29. userId: 'user-id',
  30. planCode: 'collaborator',
  31. planName: 'Collaborator',
  32. planPrice: 10,
  33. subtotal: 10,
  34. currency: 'USD',
  35. total: 10,
  36. }),
  37. }
  38. const mockSubscriptionChanges = {
  39. 'subscription-123-active': {
  40. id: 'subscription-change-id',
  41. subscriptionId: 'subscription-123-recurly-id', // not the UUID
  42. },
  43. }
  44. describe('SubscriptionHandler', function () {
  45. beforeEach(function () {
  46. this.Settings = {
  47. plans: [
  48. {
  49. planCode: 'collaborator',
  50. name: 'Collaborator',
  51. price_in_cents: 1000,
  52. features: {
  53. collaborators: -1,
  54. versioning: true,
  55. },
  56. },
  57. {
  58. planCode: 'professional',
  59. price_in_cents: 1500,
  60. },
  61. ],
  62. defaultPlanCode: {
  63. collaborators: 0,
  64. versioning: false,
  65. },
  66. }
  67. this.activeRecurlySubscription =
  68. mockRecurlySubscriptions['subscription-123-active']
  69. this.activeRecurlyClientSubscription =
  70. mockRecurlyClientSubscriptions['subscription-123-active']
  71. this.activeRecurlySubscriptionChange =
  72. mockSubscriptionChanges['subscription-123-active']
  73. this.User = {}
  74. this.user = { _id: (this.user_id = 'user_id_here_') }
  75. this.subscription = {
  76. recurlySubscription_id: this.activeRecurlySubscription.uuid,
  77. }
  78. this.RecurlyWrapper = {
  79. promises: {
  80. getSubscription: sinon.stub().resolves(this.activeRecurlySubscription),
  81. redeemCoupon: sinon.stub().resolves(),
  82. createSubscription: sinon
  83. .stub()
  84. .resolves(this.activeRecurlySubscription),
  85. getBillingInfo: sinon.stub().resolves(),
  86. getAccountPastDueInvoices: sinon.stub().resolves(),
  87. attemptInvoiceCollection: sinon.stub().resolves(),
  88. listAccountActiveSubscriptions: sinon.stub().resolves([]),
  89. },
  90. }
  91. this.RecurlyClient = {
  92. promises: {
  93. reactivateSubscriptionByUuid: sinon
  94. .stub()
  95. .resolves(this.activeRecurlyClientSubscription),
  96. cancelSubscriptionByUuid: sinon.stub().resolves(),
  97. applySubscriptionChangeRequest: sinon
  98. .stub()
  99. .resolves(this.activeRecurlySubscriptionChange),
  100. getSubscription: sinon
  101. .stub()
  102. .resolves(this.activeRecurlyClientSubscription),
  103. },
  104. }
  105. this.SubscriptionUpdater = {
  106. promises: {
  107. updateSubscriptionFromRecurly: sinon.stub().resolves(),
  108. syncSubscription: sinon.stub().resolves(),
  109. startFreeTrial: sinon.stub().resolves(),
  110. },
  111. }
  112. this.LimitationsManager = {
  113. promises: {
  114. userHasV2Subscription: sinon.stub().resolves(),
  115. },
  116. }
  117. this.SubscriptionLocator = {
  118. promises: {
  119. getUsersSubscription: sinon.stub().resolves(this.subscription),
  120. },
  121. }
  122. this.EmailHandler = {
  123. sendEmail: sinon.stub(),
  124. sendDeferredEmail: sinon.stub(),
  125. }
  126. this.UserUpdater = {
  127. promises: {
  128. updateUser: sinon.stub().resolves(),
  129. },
  130. }
  131. this.SubscriptionHandler = SandboxedModule.require(MODULE_PATH, {
  132. requires: {
  133. './RecurlyWrapper': this.RecurlyWrapper,
  134. './RecurlyClient': this.RecurlyClient,
  135. '@overleaf/settings': this.Settings,
  136. '../../models/User': {
  137. User: this.User,
  138. },
  139. './SubscriptionUpdater': this.SubscriptionUpdater,
  140. './SubscriptionLocator': this.SubscriptionLocator,
  141. './LimitationsManager': this.LimitationsManager,
  142. '../Email/EmailHandler': this.EmailHandler,
  143. '../Analytics/AnalyticsManager': this.AnalyticsManager,
  144. '../User/UserUpdater': this.UserUpdater,
  145. },
  146. })
  147. })
  148. describe('createSubscription', function () {
  149. beforeEach(function () {
  150. this.subscriptionDetails = {
  151. cvv: '123',
  152. number: '12345',
  153. }
  154. this.recurlyTokenIds = { billing: '45555666' }
  155. })
  156. describe('successfully', function () {
  157. beforeEach(async function () {
  158. await this.SubscriptionHandler.promises.createSubscription(
  159. this.user,
  160. this.subscriptionDetails,
  161. this.recurlyTokenIds
  162. )
  163. })
  164. it('should create the subscription with the wrapper', function () {
  165. this.RecurlyWrapper.promises.createSubscription
  166. .calledWith(this.user, this.subscriptionDetails, this.recurlyTokenIds)
  167. .should.equal(true)
  168. })
  169. it('should sync the subscription to the user', function () {
  170. this.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  171. true
  172. )
  173. this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  174. this.activeRecurlySubscription
  175. )
  176. this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  177. this.user._id
  178. )
  179. })
  180. it('should not set last trial date if not a trial/the trial_started_at is not set', function () {
  181. this.UserUpdater.promises.updateUser.should.not.have.been.called
  182. })
  183. })
  184. describe('when the subscription is a trial and has a trial_started_at date', function () {
  185. beforeEach(async function () {
  186. this.activeRecurlySubscription.trial_started_at =
  187. '2024-01-01T09:58:35.531+00:00'
  188. await this.SubscriptionHandler.promises.createSubscription(
  189. this.user,
  190. this.subscriptionDetails,
  191. this.recurlyTokenIds
  192. )
  193. })
  194. it('should set the users lastTrial date', function () {
  195. this.UserUpdater.promises.updateUser.should.have.been.calledOnce
  196. expect(this.UserUpdater.promises.updateUser.args[0][0]).to.deep.equal({
  197. _id: this.user_id,
  198. lastTrial: {
  199. $not: {
  200. $gt: new Date(this.activeRecurlySubscription.trial_started_at),
  201. },
  202. },
  203. })
  204. expect(this.UserUpdater.promises.updateUser.args[0][1]).to.deep.equal({
  205. $set: {
  206. lastTrial: new Date(
  207. this.activeRecurlySubscription.trial_started_at
  208. ),
  209. },
  210. })
  211. })
  212. })
  213. describe('when there is already a subscription in Recurly', function () {
  214. beforeEach(function () {
  215. this.RecurlyWrapper.promises.listAccountActiveSubscriptions.resolves([
  216. this.subscription,
  217. ])
  218. })
  219. it('should an error', function () {
  220. expect(
  221. this.SubscriptionHandler.promises.createSubscription(
  222. this.user,
  223. this.subscriptionDetails,
  224. this.recurlyTokenIds
  225. )
  226. ).to.be.rejectedWith('user already has subscription in recurly')
  227. })
  228. })
  229. })
  230. describe('updateSubscription', function () {
  231. describe('with a user with a subscription', function () {
  232. beforeEach(async function () {
  233. this.user.id = this.activeRecurlySubscription.account.account_code
  234. this.User.findById = (userId, projection) => ({
  235. exec: () => {
  236. userId.should.equal(this.user.id)
  237. return Promise.resolve(this.user)
  238. },
  239. })
  240. this.plan_code = 'professional'
  241. this.LimitationsManager.promises.userHasV2Subscription.resolves({
  242. hasSubscription: true,
  243. subscription: this.subscription,
  244. })
  245. await this.SubscriptionHandler.promises.updateSubscription(
  246. this.user,
  247. this.plan_code,
  248. null
  249. )
  250. })
  251. it('should update the subscription', function () {
  252. expect(
  253. this.RecurlyClient.promises.applySubscriptionChangeRequest
  254. ).to.have.been.calledWith(
  255. new RecurlySubscriptionChangeRequest({
  256. subscription: this.activeRecurlyClientSubscription,
  257. timeframe: 'now',
  258. planCode: this.plan_code,
  259. })
  260. )
  261. })
  262. it('should sync the new subscription to the user', function () {
  263. expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been
  264. .called
  265. this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  266. this.activeRecurlySubscription
  267. )
  268. this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  269. this.user._id
  270. )
  271. })
  272. })
  273. describe('when plan(s) could not be located in settings', function () {
  274. beforeEach(async function () {
  275. this.user.id = this.activeRecurlySubscription.account.account_code
  276. this.User.findById = (userId, projection) => ({
  277. exec: () => {
  278. userId.should.equal(this.user.id)
  279. return Promise.resolve(this.user)
  280. },
  281. })
  282. this.LimitationsManager.promises.userHasV2Subscription.resolves({
  283. hasSubscription: true,
  284. subscription: this.subscription,
  285. })
  286. })
  287. it('should be rejected and should not update the subscription', function () {
  288. expect(
  289. this.SubscriptionHandler.promises.updateSubscription(
  290. this.user,
  291. 'unknown-plan',
  292. null
  293. )
  294. ).to.be.rejected
  295. this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal(
  296. false
  297. )
  298. })
  299. })
  300. describe('with a user without a subscription', function () {
  301. beforeEach(async function () {
  302. this.LimitationsManager.promises.userHasV2Subscription.resolves(false)
  303. await this.SubscriptionHandler.promises.updateSubscription(
  304. this.user,
  305. this.plan_code,
  306. null
  307. )
  308. })
  309. it('should redirect to the subscription dashboard', function () {
  310. this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal(
  311. false
  312. )
  313. this.SubscriptionUpdater.promises.syncSubscription.called.should.equal(
  314. false
  315. )
  316. })
  317. })
  318. describe('with a coupon code', function () {
  319. beforeEach(async function () {
  320. this.user.id = this.activeRecurlySubscription.account.account_code
  321. this.User.findById = (userId, projection) => ({
  322. exec: () => {
  323. userId.should.equal(this.user.id)
  324. return Promise.resolve(this.user)
  325. },
  326. })
  327. this.plan_code = 'collaborator'
  328. this.coupon_code = '1231312'
  329. this.LimitationsManager.promises.userHasV2Subscription.resolves({
  330. hasSubscription: true,
  331. subscription: this.subscription,
  332. })
  333. await this.SubscriptionHandler.promises.updateSubscription(
  334. this.user,
  335. this.plan_code,
  336. this.coupon_code
  337. )
  338. })
  339. it('should get the users account', function () {
  340. this.RecurlyWrapper.promises.getSubscription
  341. .calledWith(this.activeRecurlySubscription.uuid)
  342. .should.equal(true)
  343. })
  344. it('should redeem the coupon', function () {
  345. this.RecurlyWrapper.promises.redeemCoupon
  346. .calledWith(
  347. this.activeRecurlySubscription.account.account_code,
  348. this.coupon_code
  349. )
  350. .should.equal(true)
  351. })
  352. it('should update the subscription', function () {
  353. expect(
  354. this.RecurlyClient.promises.applySubscriptionChangeRequest
  355. ).to.be.calledWith(
  356. new RecurlySubscriptionChangeRequest({
  357. subscription: this.activeRecurlyClientSubscription,
  358. timeframe: 'now',
  359. planCode: this.plan_code,
  360. })
  361. )
  362. })
  363. })
  364. })
  365. describe('cancelSubscription', function () {
  366. describe('with a user without a subscription', function () {
  367. beforeEach(async function () {
  368. this.LimitationsManager.promises.userHasV2Subscription.resolves({
  369. hasSubscription: false,
  370. subscription: this.subscription,
  371. })
  372. await this.SubscriptionHandler.promises.cancelSubscription(this.user)
  373. })
  374. it('should redirect to the subscription dashboard', function () {
  375. this.RecurlyClient.promises.cancelSubscriptionByUuid.called.should.equal(
  376. false
  377. )
  378. })
  379. })
  380. describe('with a user with a subscription', function () {
  381. beforeEach(async function () {
  382. this.LimitationsManager.promises.userHasV2Subscription.resolves({
  383. hasSubscription: true,
  384. subscription: this.subscription,
  385. })
  386. await this.SubscriptionHandler.promises.cancelSubscription(this.user)
  387. })
  388. it('should cancel the subscription', function () {
  389. this.RecurlyClient.promises.cancelSubscriptionByUuid.called.should.equal(
  390. true
  391. )
  392. this.RecurlyClient.promises.cancelSubscriptionByUuid
  393. .calledWith(this.subscription.recurlySubscription_id)
  394. .should.equal(true)
  395. })
  396. it('should send the email after 1 hour', function () {
  397. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  398. expect(this.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  399. 'canceledSubscription',
  400. { to: this.user.email, first_name: this.user.first_name },
  401. ONE_HOUR_IN_MS
  402. )
  403. })
  404. })
  405. })
  406. describe('reactivateSubscription', function () {
  407. describe('with a user without a subscription', function () {
  408. beforeEach(async function () {
  409. this.LimitationsManager.promises.userHasV2Subscription.resolves({
  410. hasSubscription: false,
  411. subscription: this.subscription,
  412. })
  413. await this.SubscriptionHandler.promises.reactivateSubscription(
  414. this.user
  415. )
  416. })
  417. it('should redirect to the subscription dashboard', function () {
  418. this.RecurlyClient.promises.reactivateSubscriptionByUuid.called.should.equal(
  419. false
  420. )
  421. })
  422. it('should not send a notification email', function () {
  423. sinon.assert.notCalled(this.EmailHandler.sendEmail)
  424. })
  425. })
  426. describe('with a user with a subscription', function () {
  427. beforeEach(async function () {
  428. this.LimitationsManager.promises.userHasV2Subscription.resolves({
  429. hasSubscription: true,
  430. subscription: this.subscription,
  431. })
  432. await this.SubscriptionHandler.promises.reactivateSubscription(
  433. this.user
  434. )
  435. })
  436. it('should reactivate the subscription', function () {
  437. this.RecurlyClient.promises.reactivateSubscriptionByUuid.called.should.equal(
  438. true
  439. )
  440. this.RecurlyClient.promises.reactivateSubscriptionByUuid
  441. .calledWith(this.subscription.recurlySubscription_id)
  442. .should.equal(true)
  443. })
  444. it('should send a notification email', function () {
  445. sinon.assert.calledWith(
  446. this.EmailHandler.sendEmail,
  447. 'reactivatedSubscription'
  448. )
  449. })
  450. })
  451. })
  452. describe('syncSubscription', function () {
  453. describe('with an actionable request', function () {
  454. beforeEach(async function () {
  455. this.user.id = this.activeRecurlySubscription.account.account_code
  456. this.User.findById = (userId, projection) => ({
  457. exec: () => {
  458. userId.should.equal(this.user.id)
  459. return Promise.resolve(this.user)
  460. },
  461. })
  462. await this.SubscriptionHandler.promises.syncSubscription(
  463. this.activeRecurlySubscription,
  464. {}
  465. )
  466. })
  467. it('should request the affected subscription from the API', function () {
  468. this.RecurlyWrapper.promises.getSubscription
  469. .calledWith(this.activeRecurlySubscription.uuid)
  470. .should.equal(true)
  471. })
  472. it('should request the account details of the subscription', function () {
  473. const options = this.RecurlyWrapper.promises.getSubscription.args[0][1]
  474. options.includeAccount.should.equal(true)
  475. })
  476. it('should sync the subscription to the user', function () {
  477. this.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  478. true
  479. )
  480. this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  481. this.activeRecurlySubscription
  482. )
  483. this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  484. this.user._id
  485. )
  486. })
  487. })
  488. })
  489. describe('attemptPaypalInvoiceCollection', function () {
  490. describe('for credit card users', function () {
  491. beforeEach(async function () {
  492. this.RecurlyWrapper.promises.getBillingInfo.resolves({
  493. paypal_billing_agreement_id: null,
  494. })
  495. await this.SubscriptionHandler.promises.attemptPaypalInvoiceCollection(
  496. this.activeRecurlySubscription.account.account_code
  497. )
  498. })
  499. it('gets billing infos', function () {
  500. sinon.assert.calledWith(
  501. this.RecurlyWrapper.promises.getBillingInfo,
  502. this.activeRecurlySubscription.account.account_code
  503. )
  504. })
  505. it('skips user', function () {
  506. sinon.assert.notCalled(
  507. this.RecurlyWrapper.promises.getAccountPastDueInvoices
  508. )
  509. })
  510. })
  511. describe('for paypal users', function () {
  512. beforeEach(async function () {
  513. this.RecurlyWrapper.promises.getBillingInfo.resolves({
  514. paypal_billing_agreement_id: 'mock-billing-agreement',
  515. })
  516. this.RecurlyWrapper.promises.getAccountPastDueInvoices.resolves([
  517. { invoice_number: 'mock-invoice-number' },
  518. ])
  519. await this.SubscriptionHandler.promises.attemptPaypalInvoiceCollection(
  520. this.activeRecurlySubscription.account.account_code
  521. )
  522. })
  523. it('gets past due invoices', function () {
  524. sinon.assert.calledWith(
  525. this.RecurlyWrapper.promises.getAccountPastDueInvoices,
  526. this.activeRecurlySubscription.account.account_code
  527. )
  528. })
  529. it('calls attemptInvoiceCollection', function () {
  530. sinon.assert.calledWith(
  531. this.RecurlyWrapper.promises.attemptInvoiceCollection,
  532. 'mock-invoice-number'
  533. )
  534. })
  535. })
  536. })
  537. describe('validateNoSubscriptionInRecurly', function () {
  538. describe('with a subscription in recurly', function () {
  539. beforeEach(async function () {
  540. this.RecurlyWrapper.promises.listAccountActiveSubscriptions.resolves([
  541. this.subscription,
  542. ])
  543. this.isValid =
  544. await this.SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
  545. this.user_id
  546. )
  547. })
  548. it('should call RecurlyWrapper.promises.listAccountActiveSubscriptions with the user id', function () {
  549. this.RecurlyWrapper.promises.listAccountActiveSubscriptions
  550. .calledWith(this.user_id)
  551. .should.equal(true)
  552. })
  553. it('should sync the subscription', function () {
  554. this.SubscriptionUpdater.promises.syncSubscription
  555. .calledWith(this.subscription, this.user_id)
  556. .should.equal(true)
  557. })
  558. it('should return false', function () {
  559. expect(this.isValid).to.equal(false)
  560. })
  561. })
  562. describe('with no subscription in recurly', function () {
  563. beforeEach(async function () {
  564. this.isValid =
  565. await this.SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
  566. this.user_id
  567. )
  568. })
  569. it('should be rejected and not sync the subscription', function () {
  570. this.SubscriptionUpdater.promises.syncSubscription.called.should.equal(
  571. false
  572. )
  573. })
  574. it('should return true', function () {
  575. expect(this.isValid).to.equal(true)
  576. })
  577. })
  578. })
  579. })