SubscriptionHandler.test.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const chai = require('chai')
  4. const { expect } = chai
  5. const {
  6. PaymentProviderSubscription,
  7. } = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
  8. const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper')
  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: 'Collaborator',
  16. plan_code: 'collaborator',
  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 PaymentProviderSubscription({
  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. pauseSubscriptionByUuid: sinon.stub().resolves(),
  104. resumeSubscriptionByUuid: sinon.stub().resolves(),
  105. failInvoice: sinon.stub(),
  106. getPastDueInvoices: sinon.stub(),
  107. },
  108. }
  109. this.SubscriptionUpdater = {
  110. promises: {
  111. updateSubscriptionFromRecurly: sinon.stub().resolves(),
  112. syncSubscription: sinon.stub().resolves(),
  113. syncStripeSubscription: sinon.stub().resolves(),
  114. startFreeTrial: sinon.stub().resolves(),
  115. setSubscriptionWasReverted: sinon.stub().resolves(),
  116. },
  117. }
  118. this.LimitationsManager = {
  119. promises: {
  120. userHasSubscription: sinon.stub().resolves(),
  121. },
  122. }
  123. this.SubscriptionLocator = {
  124. promises: {
  125. getUsersSubscription: sinon.stub().resolves(this.subscription),
  126. },
  127. }
  128. this.EmailHandler = {
  129. sendEmail: sinon.stub(),
  130. sendDeferredEmail: sinon.stub(),
  131. }
  132. this.UserUpdater = {
  133. promises: {
  134. updateUser: sinon.stub().resolves(),
  135. },
  136. }
  137. this.SubscriptionHandler = SandboxedModule.require(MODULE_PATH, {
  138. requires: {
  139. './RecurlyWrapper': this.RecurlyWrapper,
  140. './RecurlyClient': this.RecurlyClient,
  141. '@overleaf/settings': this.Settings,
  142. '../../models/User': {
  143. User: this.User,
  144. },
  145. './SubscriptionHelper': SubscriptionHelper,
  146. './SubscriptionUpdater': this.SubscriptionUpdater,
  147. './SubscriptionLocator': this.SubscriptionLocator,
  148. './LimitationsManager': this.LimitationsManager,
  149. '../Email/EmailHandler': this.EmailHandler,
  150. '../Analytics/AnalyticsManager': this.AnalyticsManager,
  151. '../User/UserUpdater': this.UserUpdater,
  152. '../../infrastructure/Modules': (this.Modules = {
  153. promises: {
  154. hooks: {
  155. fire: sinon.stub(),
  156. },
  157. },
  158. }),
  159. },
  160. })
  161. })
  162. describe('createSubscription', function () {
  163. beforeEach(function () {
  164. this.subscriptionDetails = {
  165. cvv: '123',
  166. number: '12345',
  167. }
  168. this.recurlyTokenIds = { billing: '45555666' }
  169. })
  170. describe('successfully', function () {
  171. beforeEach(async function () {
  172. await this.SubscriptionHandler.promises.createSubscription(
  173. this.user,
  174. this.subscriptionDetails,
  175. this.recurlyTokenIds
  176. )
  177. })
  178. it('should create the subscription with the wrapper', function () {
  179. this.RecurlyWrapper.promises.createSubscription
  180. .calledWith(this.user, this.subscriptionDetails, this.recurlyTokenIds)
  181. .should.equal(true)
  182. })
  183. it('should sync the subscription to the user', function () {
  184. this.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  185. true
  186. )
  187. this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  188. this.activeRecurlySubscription
  189. )
  190. this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  191. this.user._id
  192. )
  193. })
  194. it('should not set last trial date if not a trial/the trial_started_at is not set', function () {
  195. this.UserUpdater.promises.updateUser.should.not.have.been.called
  196. })
  197. })
  198. describe('when the subscription is a trial and has a trial_started_at date', function () {
  199. beforeEach(async function () {
  200. this.activeRecurlySubscription.trial_started_at =
  201. '2024-01-01T09:58:35.531+00:00'
  202. await this.SubscriptionHandler.promises.createSubscription(
  203. this.user,
  204. this.subscriptionDetails,
  205. this.recurlyTokenIds
  206. )
  207. })
  208. it('should set the users lastTrial date', function () {
  209. this.UserUpdater.promises.updateUser.should.have.been.calledOnce
  210. expect(this.UserUpdater.promises.updateUser.args[0][0]).to.deep.equal({
  211. _id: this.user_id,
  212. lastTrial: {
  213. $not: {
  214. $gt: new Date(this.activeRecurlySubscription.trial_started_at),
  215. },
  216. },
  217. })
  218. expect(this.UserUpdater.promises.updateUser.args[0][1]).to.deep.equal({
  219. $set: {
  220. lastTrial: new Date(
  221. this.activeRecurlySubscription.trial_started_at
  222. ),
  223. },
  224. })
  225. })
  226. })
  227. describe('when there is already a subscription in Recurly', function () {
  228. beforeEach(function () {
  229. this.RecurlyWrapper.promises.listAccountActiveSubscriptions.resolves([
  230. this.subscription,
  231. ])
  232. })
  233. it('should an error', function () {
  234. expect(
  235. this.SubscriptionHandler.promises.createSubscription(
  236. this.user,
  237. this.subscriptionDetails,
  238. this.recurlyTokenIds
  239. )
  240. ).to.be.rejectedWith('user already has subscription in recurly')
  241. })
  242. })
  243. })
  244. describe('updateSubscription', function () {
  245. beforeEach(function () {
  246. this.user.id = this.activeRecurlySubscription.account.account_code
  247. this.User.findById = (userId, projection) => ({
  248. exec: () => {
  249. userId.should.equal(this.user.id)
  250. return Promise.resolve(this.user)
  251. },
  252. })
  253. })
  254. it('should not fire updatePaidSubscription hook if user has no subscription', async function () {
  255. this.LimitationsManager.promises.userHasSubscription.resolves({
  256. hasSubscription: false,
  257. subscription: null,
  258. })
  259. await this.SubscriptionHandler.promises.updateSubscription(
  260. this.user,
  261. this.plan_code
  262. )
  263. expect(this.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  264. 'updatePaidSubscription',
  265. sinon.match.any,
  266. sinon.match.any,
  267. sinon.match.any
  268. )
  269. })
  270. it('should not fire updatePaidSubscription hook if user has custom subscription', async function () {
  271. this.LimitationsManager.promises.userHasSubscription.resolves({
  272. hasSubscription: true,
  273. subscription: { customAccount: true },
  274. })
  275. await this.SubscriptionHandler.promises.updateSubscription(
  276. this.user,
  277. this.plan_code
  278. )
  279. expect(this.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  280. 'updatePaidSubscription',
  281. sinon.match.any,
  282. sinon.match.any,
  283. sinon.match.any
  284. )
  285. })
  286. it('should fire updatePaidSubscription to update a valid subscription', async function () {
  287. this.LimitationsManager.promises.userHasSubscription.resolves({
  288. hasSubscription: true,
  289. subscription: this.subscription,
  290. })
  291. await this.SubscriptionHandler.promises.updateSubscription(
  292. this.user,
  293. this.plan_code
  294. )
  295. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  296. 'updatePaidSubscription',
  297. this.subscription,
  298. this.plan_code,
  299. this.user._id
  300. )
  301. })
  302. })
  303. describe('cancelPendingSubscriptionChange', function () {
  304. beforeEach(function () {
  305. this.user.id = this.activeRecurlySubscription.account.account_code
  306. this.User.findById = (userId, projection) => ({
  307. exec: () => {
  308. userId.should.equal(this.user.id)
  309. return Promise.resolve(this.user)
  310. },
  311. })
  312. })
  313. it('should not fire cancelPendingPaidSubscriptionChange hook if user has no subscription', async function () {
  314. this.LimitationsManager.promises.userHasSubscription.resolves({
  315. hasSubscription: false,
  316. subscription: null,
  317. })
  318. await this.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
  319. this.user,
  320. this.plan_code
  321. )
  322. expect(this.Modules.promises.hooks.fire).to.not.have.been.calledWith(
  323. 'cancelPendingPaidSubscriptionChange',
  324. sinon.match.any
  325. )
  326. })
  327. it('should fire cancelPendingPaidSubscriptionChange to update a valid subscription', async function () {
  328. this.LimitationsManager.promises.userHasSubscription.resolves({
  329. hasSubscription: true,
  330. subscription: this.subscription,
  331. })
  332. await this.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
  333. this.user,
  334. this.plan_code
  335. )
  336. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  337. 'cancelPendingPaidSubscriptionChange',
  338. this.subscription
  339. )
  340. })
  341. })
  342. describe('cancelSubscription', function () {
  343. describe('with a user without a subscription', function () {
  344. beforeEach(async function () {
  345. this.LimitationsManager.promises.userHasSubscription.resolves({
  346. hasSubscription: false,
  347. subscription: this.subscription,
  348. })
  349. await this.SubscriptionHandler.promises.cancelSubscription(this.user)
  350. })
  351. it('should redirect to the subscription dashboard', function () {
  352. this.RecurlyClient.promises.cancelSubscriptionByUuid.called.should.equal(
  353. false
  354. )
  355. })
  356. })
  357. describe('with a user with a subscription', function () {
  358. beforeEach(async function () {
  359. this.LimitationsManager.promises.userHasSubscription.resolves({
  360. hasSubscription: true,
  361. subscription: this.subscription,
  362. })
  363. await this.SubscriptionHandler.promises.cancelSubscription(this.user)
  364. })
  365. it('should cancel the subscription', function () {
  366. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  367. 'cancelPaidSubscription',
  368. this.subscription
  369. )
  370. })
  371. it('should send the email after 1 hour', function () {
  372. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  373. expect(this.EmailHandler.sendDeferredEmail).to.have.been.calledWith(
  374. 'canceledSubscription',
  375. { to: this.user.email, first_name: this.user.first_name },
  376. ONE_HOUR_IN_MS
  377. )
  378. })
  379. })
  380. })
  381. describe('resumeSubscription', function () {
  382. describe('for a user without a subscription', function () {
  383. beforeEach(async function () {
  384. this.LimitationsManager.promises.userHasSubscription.resolves({
  385. hasSubscription: false,
  386. subscription: this.subscription,
  387. })
  388. })
  389. it('should not make a resume call to recurly', async function () {
  390. expect(
  391. this.SubscriptionHandler.promises.resumeSubscription(this.user)
  392. ).to.be.rejectedWith('No active subscription to resume')
  393. this.RecurlyClient.promises.resumeSubscriptionByUuid.called.should.equal(
  394. false
  395. )
  396. })
  397. })
  398. describe('for a user with a subscription', function () {
  399. beforeEach(async function () {
  400. this.LimitationsManager.promises.userHasSubscription.resolves({
  401. hasSubscription: true,
  402. subscription: {
  403. recurlySubscription_id: this.activeRecurlySubscription.uuid,
  404. recurlyStatus: { state: 'non-trial' },
  405. planCode: 'collaborator',
  406. },
  407. })
  408. })
  409. it('should call resume hook', async function () {
  410. await this.SubscriptionHandler.promises.resumeSubscription(this.user)
  411. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  412. 'resumePaidSubscription',
  413. {
  414. recurlySubscription_id: this.activeRecurlySubscription.uuid,
  415. recurlyStatus: { state: 'non-trial' },
  416. planCode: 'collaborator',
  417. }
  418. )
  419. })
  420. })
  421. })
  422. describe('pauseSubscription', function () {
  423. describe('for a user without a subscription', function () {
  424. beforeEach(async function () {
  425. this.LimitationsManager.promises.userHasSubscription.resolves({
  426. hasSubscription: false,
  427. subscription: this.subscription,
  428. })
  429. })
  430. it('should not make a pause call to recurly', async function () {
  431. expect(
  432. this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
  433. ).to.be.rejectedWith('No active subscription to pause')
  434. this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  435. false
  436. )
  437. })
  438. })
  439. describe('for a user with an annual subscription', function () {
  440. beforeEach(async function () {
  441. this.LimitationsManager.promises.userHasSubscription.resolves({
  442. hasSubscription: false,
  443. subscription: {
  444. recurlySubscription_id: this.activeRecurlySubscription.uuid,
  445. recurlyStatus: { state: 'non-trial' },
  446. planCode: 'collaborator-annual',
  447. },
  448. })
  449. })
  450. it('should not make a pause call to recurly', async function () {
  451. expect(
  452. this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
  453. ).to.be.rejectedWith('Can only pause monthly individual plans')
  454. this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  455. false
  456. )
  457. })
  458. })
  459. describe('for a user with a subscription', function () {
  460. beforeEach(async function () {
  461. this.LimitationsManager.promises.userHasSubscription.resolves({
  462. hasSubscription: true,
  463. subscription: {
  464. recurlySubscription_id: this.activeRecurlySubscription.uuid,
  465. recurlyStatus: { state: 'non-trial' },
  466. planCode: 'collaborator',
  467. addOns: [],
  468. },
  469. })
  470. })
  471. it('should call pause hook', async function () {
  472. await this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
  473. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  474. 'pausePaidSubscription',
  475. {
  476. recurlySubscription_id: this.activeRecurlySubscription.uuid,
  477. recurlyStatus: { state: 'non-trial' },
  478. planCode: 'collaborator',
  479. addOns: [],
  480. },
  481. 3
  482. )
  483. })
  484. })
  485. describe('for a user in a trial', function () {
  486. beforeEach(async function () {
  487. this.LimitationsManager.promises.userHasSubscription.resolves({
  488. hasSubscription: true,
  489. subscription: {
  490. recurlySubscription_id: this.activeRecurlySubscription.uuid,
  491. recurlyStatus: {
  492. state: 'trial',
  493. trialEndsAt: Date.now() + 1000000,
  494. },
  495. planCode: 'collaborator',
  496. },
  497. })
  498. })
  499. it('should not make a pause call to recurly', async function () {
  500. expect(
  501. this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
  502. ).to.be.rejectedWith('Cannot pause a subscription in a trial')
  503. this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  504. false
  505. )
  506. })
  507. })
  508. describe('for a user with addons', function () {
  509. beforeEach(async function () {
  510. this.LimitationsManager.promises.userHasSubscription.resolves({
  511. hasSubscription: true,
  512. subscription: {
  513. recurlySubscription_id: this.activeRecurlySubscription.uuid,
  514. recurlyStatus: { state: 'non-trial' },
  515. planCode: 'collaborator',
  516. addOns: ['mock-addon'],
  517. },
  518. })
  519. })
  520. it('should not make a pause call to recurly', async function () {
  521. expect(
  522. this.SubscriptionHandler.promises.pauseSubscription(this.user, 3)
  523. ).to.be.rejectedWith('Cannot pause a subscription with addons')
  524. this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal(
  525. false
  526. )
  527. })
  528. })
  529. })
  530. describe('reactivateSubscription', function () {
  531. describe('with a user without a subscription', function () {
  532. beforeEach(async function () {
  533. this.LimitationsManager.promises.userHasSubscription.resolves({
  534. hasSubscription: false,
  535. subscription: this.subscription,
  536. })
  537. await this.SubscriptionHandler.promises.reactivateSubscription(
  538. this.user
  539. )
  540. })
  541. it('should redirect to the subscription dashboard', function () {
  542. this.RecurlyClient.promises.reactivateSubscriptionByUuid.called.should.equal(
  543. false
  544. )
  545. })
  546. it('should not send a notification email', function () {
  547. sinon.assert.notCalled(this.EmailHandler.sendEmail)
  548. })
  549. })
  550. describe('with a user with a subscription', function () {
  551. beforeEach(async function () {
  552. this.LimitationsManager.promises.userHasSubscription.resolves({
  553. hasSubscription: true,
  554. subscription: this.subscription,
  555. })
  556. await this.SubscriptionHandler.promises.reactivateSubscription(
  557. this.user
  558. )
  559. })
  560. it('should reactivate the subscription', function () {
  561. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  562. 'reactivatePaidSubscription',
  563. this.subscription
  564. )
  565. })
  566. it('should send a notification email', function () {
  567. sinon.assert.calledWith(
  568. this.EmailHandler.sendEmail,
  569. 'reactivatedSubscription'
  570. )
  571. })
  572. })
  573. })
  574. describe('syncSubscription', function () {
  575. describe('with an actionable request', function () {
  576. beforeEach(async function () {
  577. this.user.id = this.activeRecurlySubscription.account.account_code
  578. this.User.findById = (userId, projection) => ({
  579. exec: () => {
  580. userId.should.equal(this.user.id)
  581. return Promise.resolve(this.user)
  582. },
  583. })
  584. await this.SubscriptionHandler.promises.syncSubscription(
  585. this.activeRecurlySubscription,
  586. {}
  587. )
  588. })
  589. it('should request the affected subscription from the API', function () {
  590. this.RecurlyWrapper.promises.getSubscription
  591. .calledWith(this.activeRecurlySubscription.uuid)
  592. .should.equal(true)
  593. })
  594. it('should request the account details of the subscription', function () {
  595. const options = this.RecurlyWrapper.promises.getSubscription.args[0][1]
  596. options.includeAccount.should.equal(true)
  597. })
  598. it('should sync the subscription to the user', function () {
  599. this.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  600. true
  601. )
  602. this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  603. this.activeRecurlySubscription
  604. )
  605. this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  606. this.user._id
  607. )
  608. })
  609. })
  610. })
  611. describe('attemptPaypalInvoiceCollection', function () {
  612. describe('for credit card users', function () {
  613. beforeEach(async function () {
  614. this.RecurlyWrapper.promises.getBillingInfo.resolves({
  615. paypal_billing_agreement_id: null,
  616. })
  617. await this.SubscriptionHandler.promises.attemptPaypalInvoiceCollection(
  618. this.activeRecurlySubscription.account.account_code
  619. )
  620. })
  621. it('gets billing infos', function () {
  622. sinon.assert.calledWith(
  623. this.RecurlyWrapper.promises.getBillingInfo,
  624. this.activeRecurlySubscription.account.account_code
  625. )
  626. })
  627. it('skips user', function () {
  628. sinon.assert.notCalled(
  629. this.RecurlyWrapper.promises.getAccountPastDueInvoices
  630. )
  631. })
  632. })
  633. describe('for paypal users', function () {
  634. beforeEach(async function () {
  635. this.RecurlyWrapper.promises.getBillingInfo.resolves({
  636. paypal_billing_agreement_id: 'mock-billing-agreement',
  637. })
  638. this.RecurlyWrapper.promises.getAccountPastDueInvoices.resolves([
  639. { invoice_number: 'mock-invoice-number' },
  640. ])
  641. await this.SubscriptionHandler.promises.attemptPaypalInvoiceCollection(
  642. this.activeRecurlySubscription.account.account_code
  643. )
  644. })
  645. it('gets past due invoices', function () {
  646. sinon.assert.calledWith(
  647. this.RecurlyWrapper.promises.getAccountPastDueInvoices,
  648. this.activeRecurlySubscription.account.account_code
  649. )
  650. })
  651. it('calls attemptInvoiceCollection', function () {
  652. sinon.assert.calledWith(
  653. this.RecurlyWrapper.promises.attemptInvoiceCollection,
  654. 'mock-invoice-number'
  655. )
  656. })
  657. })
  658. })
  659. describe('validateNoSubscriptionInRecurly', function () {
  660. describe('with a subscription in recurly', function () {
  661. beforeEach(async function () {
  662. this.RecurlyWrapper.promises.listAccountActiveSubscriptions.resolves([
  663. this.subscription,
  664. ])
  665. this.isValid =
  666. await this.SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
  667. this.user_id
  668. )
  669. })
  670. it('should call RecurlyWrapper.promises.listAccountActiveSubscriptions with the user id', function () {
  671. this.RecurlyWrapper.promises.listAccountActiveSubscriptions
  672. .calledWith(this.user_id)
  673. .should.equal(true)
  674. })
  675. it('should sync the subscription', function () {
  676. this.SubscriptionUpdater.promises.syncSubscription
  677. .calledWith(this.subscription, this.user_id)
  678. .should.equal(true)
  679. })
  680. it('should return false', function () {
  681. expect(this.isValid).to.equal(false)
  682. })
  683. })
  684. describe('with no subscription in recurly', function () {
  685. beforeEach(async function () {
  686. this.isValid =
  687. await this.SubscriptionHandler.promises.validateNoSubscriptionInRecurly(
  688. this.user_id
  689. )
  690. })
  691. it('should be rejected and not sync the subscription', function () {
  692. this.SubscriptionUpdater.promises.syncSubscription.called.should.equal(
  693. false
  694. )
  695. })
  696. it('should return true', function () {
  697. expect(this.isValid).to.equal(true)
  698. })
  699. })
  700. })
  701. describe('revertPlanChange', function () {
  702. describe('with correct invoices', function () {
  703. beforeEach(async function () {
  704. this.subscriptionRestorePoint = {
  705. planCode: 'collaborator',
  706. addOns: [
  707. { addOnCode: 'addon-1', quantity: 1, unitAmountInCents: 500 },
  708. ],
  709. _id: 'restore-point-id',
  710. }
  711. this.pastDueInvoice = {
  712. id: 'invoice-123',
  713. dueAt: new Date(),
  714. collectionMethod: 'automatic',
  715. }
  716. this.user.id = this.activeRecurlySubscription.account.account_code
  717. this.User.findById = (userId, projection) => ({
  718. exec: () => {
  719. userId.should.equal(this.user.id)
  720. return Promise.resolve(this.user)
  721. },
  722. })
  723. this.RecurlyClient.promises.getSubscription.resolves(
  724. this.activeRecurlyClientSubscription
  725. )
  726. this.RecurlyClient.promises.getPastDueInvoices.resolves([
  727. this.pastDueInvoice,
  728. ])
  729. this.RecurlyClient.promises.failInvoice.resolves()
  730. this.SubscriptionUpdater.promises.setSubscriptionWasReverted.resolves()
  731. this.RecurlyClient.promises.applySubscriptionChangeRequest.resolves()
  732. await this.SubscriptionHandler.promises.revertPlanChange(
  733. this.activeRecurlyClientSubscription.id,
  734. this.subscriptionRestorePoint
  735. )
  736. })
  737. it('should fetch the subscription from recurly', async function () {
  738. expect(
  739. this.RecurlyClient.promises.getSubscription.calledWith(
  740. this.activeRecurlyClientSubscription.id
  741. )
  742. ).to.be.true
  743. })
  744. it('should fail the invoice', async function () {
  745. expect(
  746. this.RecurlyClient.promises.failInvoice.calledWith(
  747. this.pastDueInvoice.id
  748. )
  749. ).to.be.true
  750. })
  751. it('should call setSubscriptionWasReverted', async function () {
  752. expect(
  753. this.SubscriptionUpdater.promises.setSubscriptionWasReverted.calledWith(
  754. this.subscriptionRestorePoint._id
  755. )
  756. ).to.be.true
  757. })
  758. it('should sync the subscription', async function () {
  759. this.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal(
  760. true
  761. )
  762. this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
  763. this.activeRecurlySubscription
  764. )
  765. this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
  766. this.user._id
  767. )
  768. })
  769. })
  770. describe('should throw an IndeterminateInvoiceError when', function () {
  771. beforeEach(function () {
  772. this.subscriptionRestorePoint = {
  773. planCode: 'collaborator',
  774. addOns: [
  775. { addOnCode: 'addon-1', quantity: 1, unitAmountInCents: 500 },
  776. ],
  777. _id: 'restore-point-id',
  778. }
  779. this.RecurlyClient.promises.getSubscription.resolves(
  780. this.activeRecurlyClientSubscription
  781. )
  782. })
  783. it('finds a past due invoice older than 24 hours', async function () {
  784. const oldInvoice = {
  785. id: 'invoice-123',
  786. dueAt: new Date(Date.now() - 25 * 60 * 60 * 1000), // 25 hours ago
  787. collectionMethod: 'automatic',
  788. }
  789. this.RecurlyClient.promises.getPastDueInvoices.resolves([oldInvoice])
  790. await expect(
  791. this.SubscriptionHandler.promises.revertPlanChange(
  792. this.activeRecurlyClientSubscription.id,
  793. this.subscriptionRestorePoint
  794. )
  795. ).to.be.rejectedWith('cant determine invoice to fail for plan revert')
  796. })
  797. it('finds more than one past due invoice', async function () {
  798. const invoices = [
  799. {
  800. id: 'invoice-123',
  801. dueAt: new Date(),
  802. collectionMethod: 'automatic',
  803. },
  804. {
  805. id: 'invoice-456',
  806. dueAt: new Date(),
  807. collectionMethod: 'automatic',
  808. },
  809. ]
  810. this.RecurlyClient.promises.getPastDueInvoices.resolves(invoices)
  811. await expect(
  812. this.SubscriptionHandler.promises.revertPlanChange(
  813. this.activeRecurlyClientSubscription.id,
  814. this.subscriptionRestorePoint
  815. )
  816. ).to.be.rejectedWith('cant determine invoice to fail for plan revert')
  817. })
  818. it('finds an invoice with a collectionMethod other than automatic', async function () {
  819. const manualInvoice = {
  820. id: 'invoice-123',
  821. dueAt: new Date(),
  822. collectionMethod: 'manual',
  823. }
  824. this.RecurlyClient.promises.getPastDueInvoices.resolves([manualInvoice])
  825. await expect(
  826. this.SubscriptionHandler.promises.revertPlanChange(
  827. this.activeRecurlyClientSubscription.id,
  828. this.subscriptionRestorePoint
  829. )
  830. ).to.be.rejectedWith('cant determine invoice to fail for plan revert')
  831. })
  832. })
  833. })
  834. })