SubscriptionController.test.mjs 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612
  1. import { vi, assert, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import MockRequest from '../helpers/MockRequest.js'
  4. import MockResponse from '../helpers/MockResponse.js'
  5. import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.js'
  6. import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js'
  7. import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.js'
  8. const modulePath =
  9. '../../../../app/src/Features/Subscription/SubscriptionController.mjs'
  10. const mockSubscriptions = {
  11. 'subscription-123-active': {
  12. uuid: 'subscription-123-active',
  13. plan: {
  14. name: 'Gold',
  15. plan_code: 'gold',
  16. },
  17. current_period_ends_at: new Date(),
  18. state: 'active',
  19. unit_amount_in_cents: 999,
  20. account: {
  21. account_code: 'user-123',
  22. },
  23. },
  24. }
  25. describe('SubscriptionController', function () {
  26. beforeEach(async function (ctx) {
  27. ctx.logger = {
  28. debug: sinon.stub(),
  29. warn: sinon.stub(),
  30. }
  31. vi.doMock('@overleaf/logger', () => ({
  32. default: ctx.logger,
  33. }))
  34. ctx.user = {
  35. email: 'tom@yahoo.com',
  36. _id: 'one',
  37. signUpDate: new Date('2000-10-01'),
  38. emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }],
  39. }
  40. ctx.activeRecurlySubscription = mockSubscriptions['subscription-123-active']
  41. ctx.SessionManager = {
  42. getLoggedInUser: sinon.stub().callsArgWith(1, null, ctx.user),
  43. getLoggedInUserId: sinon.stub().returns(ctx.user._id),
  44. getSessionUser: sinon.stub().returns(ctx.user),
  45. isUserLoggedIn: sinon.stub().returns(true),
  46. }
  47. ctx.SubscriptionHandler = {
  48. createSubscription: sinon.stub().callsArgWith(3),
  49. updateSubscription: sinon.stub().callsArgWith(3),
  50. reactivateSubscription: sinon.stub().callsArgWith(1),
  51. cancelSubscription: sinon.stub().callsArgWith(1),
  52. syncSubscription: sinon.stub().yields(),
  53. attemptPaypalInvoiceCollection: sinon.stub().yields(),
  54. startFreeTrial: sinon.stub(),
  55. revertPlanChange: sinon.stub(),
  56. promises: {
  57. createSubscription: sinon.stub().resolves(),
  58. updateSubscription: sinon.stub().resolves(),
  59. reactivateSubscription: sinon.stub().resolves(),
  60. cancelSubscription: sinon.stub().resolves(),
  61. pauseSubscription: sinon.stub().resolves(),
  62. resumeSubscription: sinon.stub().resolves(),
  63. syncSubscription: sinon.stub().resolves(),
  64. attemptPaypalInvoiceCollection: sinon.stub().resolves(),
  65. startFreeTrial: sinon.stub().resolves(),
  66. purchaseAddon: sinon.stub().resolves(),
  67. previewAddonPurchase: sinon.stub().resolves({
  68. subscription: {
  69. currency: 'USD',
  70. netTerms: 0,
  71. periodEnd: new Date(),
  72. taxRate: 0,
  73. },
  74. immediateCharge: { amount: 0 },
  75. nextPlanCode: 'professional',
  76. nextPlanName: 'Professional',
  77. nextPlanPrice: 2000,
  78. nextAddOns: [],
  79. subtotal: 2000,
  80. tax: 0,
  81. total: 2000,
  82. }),
  83. revertPlanChange: sinon.stub().resolves(),
  84. },
  85. }
  86. ctx.LimitationsManager = {
  87. hasPaidSubscription: sinon.stub(),
  88. userHasSubscription: sinon
  89. .stub()
  90. .yields(null, { hasSubscription: false }),
  91. promises: {
  92. hasPaidSubscription: sinon.stub().resolves(),
  93. userHasSubscription: sinon.stub().resolves({ hasSubscription: false }),
  94. },
  95. }
  96. ctx.SubscriptionViewModelBuilder = {
  97. buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}),
  98. buildPlansList: sinon.stub(),
  99. promises: {
  100. buildUsersSubscriptionViewModel: sinon.stub().resolves({}),
  101. },
  102. buildPlansListForSubscriptionDash: sinon
  103. .stub()
  104. .returns({ plans: [], planCodesChangingAtTermEnd: [] }),
  105. }
  106. ctx.settings = {
  107. coupon_codes: {
  108. upgradeToAnnualPromo: {
  109. student: 'STUDENTCODEHERE',
  110. collaborator: 'COLLABORATORCODEHERE',
  111. },
  112. },
  113. groupPlanModalOptions: {
  114. plan_codes: [],
  115. currencies: [
  116. {
  117. display: 'GBP (£)',
  118. code: 'GBP',
  119. },
  120. ],
  121. sizes: ['42'],
  122. usages: [{ code: 'foo', display: 'Foo' }],
  123. },
  124. apis: {
  125. recurly: {
  126. subdomain: 'sl',
  127. },
  128. },
  129. planReverts: {
  130. enabled: false,
  131. },
  132. siteUrl: 'http://de.overleaf.dev:3000',
  133. }
  134. ctx.AuthorizationManager = {
  135. promises: {
  136. isUserSiteAdmin: sinon.stub().resolves(false),
  137. },
  138. }
  139. ctx.GeoIpLookup = {
  140. isValidCurrencyParam: sinon.stub().returns(true),
  141. getCurrencyCode: sinon.stub().yields('USD', 'US'),
  142. promises: {
  143. getCurrencyCode: sinon.stub().resolves({
  144. countryCode: 'US',
  145. currencyCode: 'USD',
  146. }),
  147. },
  148. }
  149. ctx.UserGetter = {
  150. getUser: sinon.stub().callsArgWith(2, null, ctx.user),
  151. promises: {
  152. getUser: sinon.stub().resolves(ctx.user),
  153. getWritefullData: sinon
  154. .stub()
  155. .resolves({ isPremium: false, premiumSource: null }),
  156. },
  157. }
  158. ctx.SplitTestV2Hander = {
  159. promises: {
  160. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  161. },
  162. }
  163. ctx.Features = {
  164. hasFeature: sinon.stub().returns(false),
  165. }
  166. vi.doMock(
  167. '../../../../app/src/Features/Authorization/AuthorizationManager',
  168. () => ({
  169. default: ctx.AuthorizationManager,
  170. })
  171. )
  172. vi.doMock(
  173. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  174. () => ({
  175. default: ctx.SplitTestV2Hander,
  176. })
  177. )
  178. vi.doMock(
  179. '../../../../app/src/Features/Authentication/SessionManager',
  180. () => ({
  181. default: ctx.SessionManager,
  182. })
  183. )
  184. vi.doMock(
  185. '../../../../app/src/Features/Subscription/SubscriptionHandler',
  186. () => ({
  187. default: ctx.SubscriptionHandler,
  188. })
  189. )
  190. vi.doMock(
  191. '../../../../app/src/Features/Subscription/SubscriptionHelper',
  192. () => ({
  193. default: SubscriptionHelper,
  194. })
  195. )
  196. vi.doMock(
  197. '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
  198. () => ({
  199. default: ctx.SubscriptionViewModelBuilder,
  200. })
  201. )
  202. vi.doMock(
  203. '../../../../app/src/Features/Subscription/LimitationsManager',
  204. () => ({
  205. default: ctx.LimitationsManager,
  206. })
  207. )
  208. vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({
  209. default: ctx.GeoIpLookup,
  210. }))
  211. vi.doMock('@overleaf/settings', () => ({
  212. default: ctx.settings,
  213. }))
  214. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  215. default: ctx.UserGetter,
  216. }))
  217. vi.doMock(
  218. '../../../../app/src/Features/Subscription/RecurlyWrapper',
  219. () => ({
  220. default: (ctx.RecurlyWrapper = {
  221. promises: {
  222. updateAccountEmailAddress: sinon.stub().resolves(),
  223. getSubscription: sinon.stub().resolves({}),
  224. },
  225. }),
  226. })
  227. )
  228. vi.doMock(
  229. '../../../../app/src/Features/Subscription/RecurlyEventHandler',
  230. () => ({
  231. default: {
  232. sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
  233. },
  234. })
  235. )
  236. vi.doMock(
  237. '../../../../app/src/Features/Subscription/FeaturesUpdater',
  238. () => ({
  239. default: (ctx.FeaturesUpdater = {
  240. promises: {
  241. refreshFeatures: sinon.stub().resolves({ features: {} }),
  242. },
  243. }),
  244. })
  245. )
  246. vi.doMock('celebrate', () => ({
  247. default: (ctx.celebrate = {
  248. celebrate: sinon.stub(),
  249. errors: sinon.stub(),
  250. Joi: {
  251. any: sinon.stub(),
  252. extend: sinon.stub(),
  253. },
  254. }),
  255. }))
  256. vi.doMock(
  257. '../../../../app/src/Features/Subscription/GroupPlansData',
  258. () => ({
  259. default: (ctx.GroupPlansData = {}),
  260. })
  261. )
  262. vi.doMock(
  263. '../../../../app/src/Features/Subscription/V1SubscriptionManager',
  264. () => ({
  265. default: (ctx.V1SubscriptionManager = {}),
  266. })
  267. )
  268. vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
  269. default: (ctx.HttpErrorHandler = {
  270. unprocessableEntity: sinon.stub().callsFake((req, res, message) => {
  271. res.status(422)
  272. res.json({ message })
  273. }),
  274. badRequest: sinon.stub().callsFake((req, res, message) => {
  275. res.status(400)
  276. res.json({ message })
  277. }),
  278. }),
  279. }))
  280. vi.doMock('../../../../app/src/Features/Subscription/Errors', () => ({
  281. default: SubscriptionErrors,
  282. }))
  283. vi.doMock(
  284. '../../../../app/src/Features/Analytics/AnalyticsManager',
  285. () => ({
  286. default: (ctx.AnalyticsManager = {
  287. recordEventForUser: sinon.stub(),
  288. recordEventForUserInBackground: sinon.stub(),
  289. recordEventForSession: sinon.stub(),
  290. setUserPropertyForUser: sinon.stub(),
  291. }),
  292. })
  293. )
  294. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  295. default: (ctx.Modules = {
  296. promises: { hooks: { fire: sinon.stub().resolves() } },
  297. }),
  298. }))
  299. vi.doMock('../../../../app/src/infrastructure/Features', () => ({
  300. default: ctx.Features,
  301. }))
  302. vi.doMock('../../../../app/src/util/currency', () => ({
  303. default: (ctx.currency = {
  304. formatCurrency: sinon.stub(),
  305. }),
  306. }))
  307. vi.doMock('../../../../app/src/models/User', () => ({
  308. User: {
  309. findById: sinon.stub().resolves(ctx.user),
  310. },
  311. }))
  312. vi.doMock(
  313. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  314. () => ({
  315. default: (ctx.SubscriptionLocator = {
  316. promises: {
  317. getUsersSubscription: sinon.stub().resolves(null),
  318. },
  319. }),
  320. })
  321. )
  322. vi.doMock(
  323. '../../../../app/src/Features/Authorization/PermissionsManager',
  324. () => ({
  325. default: (ctx.PermissionsManager = {
  326. promises: {
  327. checkUserPermissions: sinon.stub().resolves(true),
  328. },
  329. }),
  330. })
  331. )
  332. vi.doMock(
  333. '../../../../app/src/Features/Subscription/RecurlyClient',
  334. () => ({
  335. default: (ctx.RecurlyClient = {
  336. promises: {
  337. getAddOn: sinon.stub().resolves({
  338. code: 'ai-assistant',
  339. name: 'AI Assistant',
  340. }),
  341. },
  342. }),
  343. })
  344. )
  345. vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
  346. default: (ctx.PlansLocator = {
  347. findLocalPlanInSettings: sinon.stub().returns({
  348. annual: false,
  349. }),
  350. }),
  351. }))
  352. ctx.SubscriptionController = (await import(modulePath)).default
  353. ctx.res = new MockResponse()
  354. ctx.req = new MockRequest()
  355. ctx.req.body = {}
  356. ctx.req.query = { planCode: '123123' }
  357. ctx.stubbedCurrencyCode = 'GBP'
  358. })
  359. describe('successfulSubscription', function () {
  360. it('without a personal subscription', async function (ctx) {
  361. await new Promise(resolve => {
  362. ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  363. {}
  364. )
  365. ctx.res.redirect = url => {
  366. url.should.equal('/user/subscription/plans')
  367. resolve()
  368. }
  369. ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
  370. })
  371. })
  372. it('with a personal subscription', async function (ctx) {
  373. await new Promise(resolve => {
  374. ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  375. {
  376. personalSubscription: 'foo',
  377. }
  378. )
  379. ctx.res.render = (url, variables) => {
  380. url.should.equal('subscriptions/successful-subscription-react')
  381. assert.deepEqual(variables, {
  382. title: 'thank_you',
  383. personalSubscription: 'foo',
  384. postCheckoutRedirect: undefined,
  385. user: {
  386. _id: ctx.user._id,
  387. features: ctx.user.features,
  388. },
  389. })
  390. resolve()
  391. }
  392. ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
  393. })
  394. })
  395. it('with an error', async function (ctx) {
  396. await new Promise(resolve => {
  397. ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  398. undefined
  399. )
  400. ctx.SubscriptionController.successfulSubscription(
  401. ctx.req,
  402. ctx.res,
  403. error => {
  404. assert.isNotNull(error)
  405. resolve()
  406. }
  407. )
  408. })
  409. })
  410. })
  411. describe('userSubscriptionPage', function () {
  412. beforeEach(async function (ctx) {
  413. await new Promise((resolve, reject) => {
  414. ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
  415. {
  416. personalSubscription: (ctx.personalSubscription = {
  417. 'personal-subscription': 'mock',
  418. }),
  419. memberGroupSubscriptions: (ctx.memberGroupSubscriptions = {
  420. 'group-subscriptions': 'mock',
  421. }),
  422. }
  423. )
  424. ctx.SubscriptionViewModelBuilder.buildPlansList.returns(
  425. (ctx.plans = { plans: 'mock' })
  426. )
  427. ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns(
  428. {
  429. plans: ctx.plans,
  430. planCodesChangingAtTermEnd: [],
  431. }
  432. )
  433. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  434. hasSubscription: false,
  435. })
  436. ctx.res.render = (view, data) => {
  437. ctx.data = data
  438. expect(view).to.equal('subscriptions/dashboard-react')
  439. resolve()
  440. }
  441. ctx.SubscriptionController.userSubscriptionPage(
  442. ctx.req,
  443. ctx.res,
  444. ctx.rejectOnError(reject)
  445. )
  446. })
  447. })
  448. it('should load the personal, groups and v1 subscriptions', function (ctx) {
  449. expect(ctx.data.personalSubscription).to.deep.equal(
  450. ctx.personalSubscription
  451. )
  452. expect(ctx.data.memberGroupSubscriptions).to.deep.equal(
  453. ctx.memberGroupSubscriptions
  454. )
  455. })
  456. it('should load the user', function (ctx) {
  457. expect(ctx.data.user).to.deep.equal(ctx.user)
  458. })
  459. it('should load the plans', function (ctx) {
  460. expect(ctx.data.plans).to.deep.equal(ctx.plans)
  461. })
  462. it('should load an empty list of groups with settings available', function (ctx) {
  463. expect(ctx.data.groupSettingsEnabledFor).to.deep.equal([])
  464. })
  465. describe('when errorCode query param is present', function () {
  466. beforeEach(async function (ctx) {
  467. ctx.req.query.errorCode = 'payment_failed'
  468. await new Promise((resolve, reject) => {
  469. ctx.res.render = (view, data) => {
  470. ctx.data = data
  471. expect(view).to.equal('subscriptions/dashboard-react')
  472. resolve()
  473. }
  474. ctx.SubscriptionController.userSubscriptionPage(
  475. ctx.req,
  476. ctx.res,
  477. ctx.rejectOnError(reject)
  478. )
  479. })
  480. })
  481. it('should pass redirectedPaymentErrorCode to the view', function (ctx) {
  482. expect(ctx.data.redirectedPaymentErrorCode).to.equal('payment_failed')
  483. })
  484. })
  485. })
  486. describe('updateAccountEmailAddress via put', function () {
  487. beforeEach(function (ctx) {
  488. ctx.req.body = {
  489. account_email: 'current_account_email@overleaf.com',
  490. }
  491. })
  492. it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function (ctx) {
  493. ctx.res.sendStatus = sinon.spy()
  494. await ctx.SubscriptionController.updateAccountEmailAddress(
  495. ctx.req,
  496. ctx.res
  497. )
  498. expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
  499. 'updateAccountEmailAddress',
  500. ctx.user._id,
  501. ctx.user.email
  502. )
  503. })
  504. it('should respond with 200', async function (ctx) {
  505. ctx.res.sendStatus = sinon.spy()
  506. await ctx.SubscriptionController.updateAccountEmailAddress(
  507. ctx.req,
  508. ctx.res
  509. )
  510. ctx.res.sendStatus.calledWith(200).should.equal(true)
  511. })
  512. it('should send the error to the next handler when updating recurly account email fails', async function (ctx) {
  513. ctx.Modules.promises.hooks.fire
  514. .withArgs('updateAccountEmailAddress', ctx.user._id, ctx.user.email)
  515. .rejects(new Error())
  516. ctx.next = sinon.spy(error => {
  517. expect(error).to.be.instanceOf(Error)
  518. })
  519. await ctx.SubscriptionController.updateAccountEmailAddress(
  520. ctx.req,
  521. ctx.res,
  522. ctx.next
  523. )
  524. expect(ctx.next.calledOnce).to.be.true
  525. })
  526. })
  527. describe('reactivateSubscription', function () {
  528. describe('when the user has permission', function () {
  529. beforeEach(async function (ctx) {
  530. await new Promise(resolve => {
  531. ctx.res = {
  532. redirect() {
  533. resolve()
  534. },
  535. }
  536. ctx.req.assertPermission = sinon.stub()
  537. ctx.next = sinon.stub().callsFake(error => {
  538. resolve(error)
  539. })
  540. sinon.spy(ctx.res, 'redirect')
  541. ctx.SubscriptionController.reactivateSubscription(
  542. ctx.req,
  543. ctx.res,
  544. ctx.next
  545. )
  546. })
  547. })
  548. it('should assert the user has permission to reactivate their subscription', async function (ctx) {
  549. await new Promise(resolve => {
  550. ctx.req.assertPermission
  551. .calledWith('reactivate-subscription')
  552. .should.equal(true)
  553. resolve()
  554. })
  555. })
  556. it('should tell the handler to reactivate this user', async function (ctx) {
  557. await new Promise(resolve => {
  558. ctx.SubscriptionHandler.reactivateSubscription
  559. .calledWith(ctx.user)
  560. .should.equal(true)
  561. resolve()
  562. })
  563. })
  564. it('should redurect to the subscription page', async function (ctx) {
  565. await new Promise(resolve => {
  566. ctx.res.redirect.calledWith('/user/subscription').should.equal(true)
  567. resolve()
  568. })
  569. })
  570. })
  571. describe('when the user does not have permission', function () {
  572. beforeEach(async function (ctx) {
  573. await new Promise(resolve => {
  574. ctx.res = {
  575. redirect() {
  576. resolve()
  577. },
  578. }
  579. ctx.req.assertPermission = sinon.stub().throws()
  580. ctx.next = sinon.stub().callsFake(() => {
  581. resolve()
  582. })
  583. sinon.spy(ctx.res, 'redirect')
  584. ctx.SubscriptionController.reactivateSubscription(
  585. ctx.req,
  586. ctx.res,
  587. ctx.next
  588. )
  589. })
  590. })
  591. it('should not reactivate the user', async function (ctx) {
  592. await new Promise(resolve => {
  593. ctx.req.assertPermission = sinon.stub().throws()
  594. ctx.SubscriptionHandler.reactivateSubscription.called.should.equal(
  595. false
  596. )
  597. resolve()
  598. })
  599. })
  600. it('should call next with an error', async function (ctx) {
  601. await new Promise(resolve => {
  602. ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
  603. resolve()
  604. })
  605. })
  606. })
  607. })
  608. describe('pauseSubscription', function () {
  609. it('should throw an error if no pause length is provided', async function (ctx) {
  610. ctx.res = new MockResponse()
  611. ctx.req = new MockRequest()
  612. ctx.next = sinon.stub()
  613. await expect(
  614. ctx.SubscriptionController.pauseSubscription(ctx.req, ctx.res, ctx.next)
  615. ).to.be.rejectedWith('Not found')
  616. })
  617. it('should throw an error if an invalid pause length is provided', async function (ctx) {
  618. ctx.res = new MockResponse()
  619. ctx.req = new MockRequest()
  620. ctx.req.params = { pauseCycles: '-10' }
  621. ctx.next = sinon.stub()
  622. await ctx.SubscriptionController.pauseSubscription(
  623. ctx.req,
  624. ctx.res,
  625. ctx.next
  626. )
  627. expect(ctx.res.statusCode).to.equal(400)
  628. })
  629. it('should return a 200 when requesting a pause', async function (ctx) {
  630. ctx.res = new MockResponse()
  631. ctx.req = new MockRequest()
  632. ctx.req.params = { pauseCycles: '3' }
  633. ctx.next = sinon.stub()
  634. await ctx.SubscriptionController.pauseSubscription(
  635. ctx.req,
  636. ctx.res,
  637. ctx.next
  638. )
  639. expect(ctx.res.statusCode).to.equal(200)
  640. })
  641. })
  642. describe('resumeSubscription', function () {
  643. it('should return a 200 when resuming a subscription', async function (ctx) {
  644. ctx.res = new MockResponse()
  645. ctx.req = new MockRequest()
  646. ctx.next = sinon.stub()
  647. await ctx.SubscriptionController.resumeSubscription(
  648. ctx.req,
  649. ctx.res,
  650. ctx.next
  651. )
  652. expect(ctx.res.statusCode).to.equal(200)
  653. })
  654. })
  655. describe('cancelSubscription', function () {
  656. it('should tell the handler to cancel this user', async function (ctx) {
  657. ctx.next = sinon.stub()
  658. await ctx.SubscriptionController.cancelSubscription(
  659. ctx.req,
  660. ctx.res,
  661. ctx.next
  662. )
  663. ctx.SubscriptionHandler.promises.cancelSubscription
  664. .calledWith(ctx.user)
  665. .should.equal(true)
  666. })
  667. it('should return a 200 on success', async function (ctx) {
  668. ctx.next = sinon.stub()
  669. await ctx.SubscriptionController.cancelSubscription(
  670. ctx.req,
  671. ctx.res,
  672. ctx.next
  673. )
  674. expect(ctx.res.statusCode).to.equal(200)
  675. })
  676. it('should call next with error', async function (ctx) {
  677. ctx.SubscriptionHandler.promises.cancelSubscription.rejects(
  678. new Error('cancel error')
  679. )
  680. ctx.next = sinon.stub()
  681. await ctx.SubscriptionController.cancelSubscription(
  682. ctx.req,
  683. ctx.res,
  684. ctx.next
  685. )
  686. ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
  687. })
  688. })
  689. describe('recurly callback', function () {
  690. describe('with a sync subscription request', function () {
  691. beforeEach(async function (ctx) {
  692. await new Promise(resolve => {
  693. ctx.req = {
  694. body: {
  695. expired_subscription_notification: {
  696. account: {
  697. account_code: ctx.user._id,
  698. },
  699. subscription: {
  700. uuid: ctx.activeRecurlySubscription.uuid,
  701. plan: {
  702. plan_code: 'collaborator',
  703. state: 'active',
  704. },
  705. },
  706. },
  707. },
  708. }
  709. ctx.res = {
  710. sendStatus() {
  711. resolve()
  712. },
  713. }
  714. sinon.spy(ctx.res, 'sendStatus')
  715. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  716. })
  717. })
  718. it('should tell the SubscriptionHandler to process the recurly callback', async function (ctx) {
  719. await new Promise(resolve => {
  720. ctx.SubscriptionHandler.syncSubscription.called.should.equal(true)
  721. resolve()
  722. })
  723. })
  724. it('should send a 200', async function (ctx) {
  725. await new Promise(resolve => {
  726. ctx.res.sendStatus.calledWith(200)
  727. resolve()
  728. })
  729. })
  730. })
  731. describe('with a billing info updated request', function () {
  732. beforeEach(async function (ctx) {
  733. await new Promise(resolve => {
  734. ctx.req = {
  735. body: {
  736. billing_info_updated_notification: {
  737. account: {
  738. account_code: 'mock-account-code',
  739. },
  740. },
  741. },
  742. }
  743. ctx.res = {
  744. sendStatus() {
  745. resolve()
  746. },
  747. }
  748. sinon.spy(ctx.res, 'sendStatus')
  749. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  750. })
  751. })
  752. it('should call attemptPaypalInvoiceCollection', async function (ctx) {
  753. await new Promise(resolve => {
  754. ctx.SubscriptionHandler.attemptPaypalInvoiceCollection
  755. .calledWith('mock-account-code')
  756. .should.equal(true)
  757. resolve()
  758. })
  759. })
  760. it('should send a 200', async function (ctx) {
  761. await new Promise(resolve => {
  762. ctx.res.sendStatus.calledWith(200)
  763. resolve()
  764. })
  765. })
  766. })
  767. describe('with a non-actionable request', function () {
  768. beforeEach(async function (ctx) {
  769. await new Promise(resolve => {
  770. ctx.user.id = ctx.activeRecurlySubscription.account.account_code
  771. ctx.req = {
  772. body: {
  773. renewed_subscription_notification: {
  774. account: {
  775. account_code: ctx.user._id,
  776. },
  777. subscription: {
  778. uuid: ctx.activeRecurlySubscription.uuid,
  779. plan: {
  780. plan_code: 'collaborator',
  781. state: 'active',
  782. },
  783. },
  784. },
  785. },
  786. }
  787. ctx.res = {
  788. sendStatus() {
  789. resolve()
  790. },
  791. }
  792. sinon.spy(ctx.res, 'sendStatus')
  793. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  794. })
  795. })
  796. it('should not call the subscriptionshandler', function (ctx) {
  797. ctx.SubscriptionHandler.syncSubscription.called.should.equal(false)
  798. ctx.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
  799. false
  800. )
  801. })
  802. it('should respond with a 200 status', function (ctx) {
  803. ctx.res.sendStatus.calledWith(200)
  804. })
  805. })
  806. describe('with a failed payment notification', function () {
  807. describe('with planReverts disabled in settings', function () {
  808. beforeEach(async function (ctx) {
  809. await new Promise(resolve => {
  810. ctx.settings.planReverts = { enabled: false }
  811. ctx.SubscriptionHandler.revertPlanChange = sinon.stub()
  812. ctx.req.body = {
  813. failed_payment_notification: {
  814. transaction: {
  815. subscription_id: 'subscription-123',
  816. },
  817. },
  818. }
  819. ctx.res = {
  820. sendStatus() {
  821. resolve()
  822. },
  823. }
  824. sinon.spy(ctx.res, 'sendStatus')
  825. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  826. })
  827. })
  828. it('should not call revertPlanChange', function (ctx) {
  829. expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false
  830. })
  831. it('should respond with 200', async function (ctx) {
  832. await new Promise(resolve => {
  833. ctx.res.sendStatus.calledWith(200)
  834. resolve()
  835. })
  836. })
  837. })
  838. describe('with planReverts enabled in settings', function () {
  839. beforeEach(function (ctx) {
  840. ctx.settings.planReverts = { enabled: true }
  841. })
  842. describe('with no valid restore point', function () {
  843. beforeEach(async function (ctx) {
  844. await new Promise(resolve => {
  845. ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon
  846. .stub()
  847. .yields(null, null)
  848. ctx.SubscriptionHandler.revertPlanChange = sinon.stub()
  849. ctx.req.body = {
  850. failed_payment_notification: {
  851. transaction: {
  852. subscription_id: 'subscription-123',
  853. },
  854. },
  855. }
  856. ctx.res = {
  857. sendStatus() {
  858. resolve()
  859. },
  860. }
  861. sinon.spy(ctx.res, 'sendStatus')
  862. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  863. })
  864. })
  865. it('should not call revertPlanChange()', function (ctx) {
  866. expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false
  867. })
  868. it('should respond with 200', function (ctx) {
  869. ctx.res.sendStatus.calledWith(200)
  870. })
  871. })
  872. describe('with a valid restore point', function () {
  873. beforeEach(async function (ctx) {
  874. await new Promise(resolve => {
  875. ctx.addOns = [
  876. {
  877. addOnCode: 'addon-1',
  878. quantity: 2,
  879. unitAmountInCents: 500,
  880. },
  881. {
  882. addOnCode: 'addon-2',
  883. quantity: 1,
  884. unitAmountInCents: 600,
  885. },
  886. ]
  887. ctx.lastSubscription = {
  888. planCode: 'gold',
  889. addOns: ctx.addOns,
  890. }
  891. ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon
  892. .stub()
  893. .yields(null, ctx.lastSubscription)
  894. ctx.SubscriptionHandler.revertPlanChange = sinon.stub().yields()
  895. ctx.req.body = {
  896. failed_payment_notification: {
  897. transaction: {
  898. subscription_id: 'subscription-123',
  899. },
  900. },
  901. }
  902. ctx.res = {
  903. sendStatus() {
  904. resolve()
  905. },
  906. }
  907. sinon.spy(ctx.res, 'sendStatus')
  908. ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
  909. })
  910. })
  911. it('should get the subscription restore point', function (ctx) {
  912. expect(
  913. ctx.SubscriptionHandler.getSubscriptionRestorePoint.calledWith(
  914. 'subscription-123'
  915. )
  916. ).to.be.true
  917. })
  918. it('should call revertPlanChange()', function (ctx) {
  919. expect(
  920. ctx.SubscriptionHandler.revertPlanChange.calledWith(
  921. 'subscription-123',
  922. ctx.lastSubscription
  923. )
  924. ).to.be.true
  925. })
  926. it('should respond with 200', function (ctx) {
  927. ctx.res.sendStatus.calledWith(200)
  928. })
  929. })
  930. })
  931. })
  932. })
  933. describe('purchaseAddon', function () {
  934. beforeEach(function (ctx) {
  935. ctx.SessionManager.getSessionUser.returns(ctx.user) // Make sure getSessionUser returns the user
  936. ctx.next = sinon.stub()
  937. ctx.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code
  938. })
  939. it('should return 200 on successful purchase of AI add-on', async function (ctx) {
  940. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  941. ctx.res.sendStatus = sinon.spy()
  942. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  943. expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.have.been.called
  944. expect(
  945. ctx.SubscriptionHandler.promises.purchaseAddon
  946. ).to.have.been.calledWith(ctx.user._id, AI_ADD_ON_CODE, 1)
  947. expect(
  948. ctx.FeaturesUpdater.promises.refreshFeatures
  949. ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
  950. expect(ctx.res.sendStatus).to.have.been.calledWith(200)
  951. expect(ctx.logger.debug).to.have.been.calledWith(
  952. { userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE },
  953. 'purchasing add-ons'
  954. )
  955. })
  956. it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
  957. ctx.req.params = { addOnCode: 'some-other-addon' }
  958. ctx.res.sendStatus = sinon.spy()
  959. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  960. expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
  961. .called
  962. expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
  963. .called
  964. expect(ctx.res.sendStatus).to.have.been.calledWith(404)
  965. })
  966. it('should handle DuplicateAddOnError and send badRequest while sending 200', async function (ctx) {
  967. ctx.req.params.addOnCode = AI_ADD_ON_CODE
  968. ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
  969. new SubscriptionErrors.DuplicateAddOnError()
  970. )
  971. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  972. expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
  973. ctx.req,
  974. ctx.res,
  975. 'Your subscription already includes this add-on',
  976. { addon: AI_ADD_ON_CODE }
  977. )
  978. expect(
  979. ctx.FeaturesUpdater.promises.refreshFeatures
  980. ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
  981. expect(ctx.res.sendStatus).to.have.been.calledWith(200)
  982. })
  983. it('should handle PaymentActionRequiredError and return 402 with details', async function (ctx) {
  984. ctx.req.params.addOnCode = AI_ADD_ON_CODE
  985. const paymentError = new SubscriptionErrors.PaymentActionRequiredError({
  986. clientSecret: 'secret123',
  987. publicKey: 'pubkey456',
  988. })
  989. ctx.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError)
  990. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
  991. ctx.res.status.calledWith(402).should.equal(true)
  992. ctx.res.json
  993. .calledWith({
  994. message: 'Payment action required',
  995. clientSecret: 'secret123',
  996. publicKey: 'pubkey456',
  997. })
  998. .should.equal(true)
  999. expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
  1000. .called
  1001. })
  1002. it('should refresh features', async function (ctx) {
  1003. ctx.req.params.addOnCode = 'assistant'
  1004. ctx.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves()
  1005. ctx.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves()
  1006. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
  1007. expect(
  1008. ctx.FeaturesUpdater.promises.refreshFeatures.calledWith(
  1009. ctx.user._id,
  1010. 'add-on-purchase'
  1011. )
  1012. ).to.be.true
  1013. })
  1014. it('should respond with a bad request if the subscription already includes the addOn', async function (ctx) {
  1015. ctx.req.params.addOnCode = 'assistant'
  1016. ctx.SubscriptionHandler.promises.purchaseAddon = sinon
  1017. .stub()
  1018. .rejects(new SubscriptionErrors.DuplicateAddOnError())
  1019. await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
  1020. expect(
  1021. ctx.HttpErrorHandler.badRequest.calledWith(
  1022. ctx.req,
  1023. ctx.res,
  1024. 'Your subscription already includes this add-on',
  1025. { addon: 'assistant' }
  1026. )
  1027. ).to.be.true
  1028. })
  1029. })
  1030. describe('checkSubscriptionPauseStatus', function () {
  1031. beforeEach(function (ctx) {
  1032. ctx.user = {
  1033. _id: 'user-id-123',
  1034. email: 'test@example.com',
  1035. }
  1036. })
  1037. it('should return isPaused: false when user has no subscription', async function (ctx) {
  1038. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1039. subscription: null,
  1040. })
  1041. const result =
  1042. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1043. expect(result).to.deep.equal({ isPaused: false })
  1044. })
  1045. it('should return isPaused: false when subscription has no paymentProvider', async function (ctx) {
  1046. const subscription = {
  1047. planCode: 'professional',
  1048. }
  1049. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1050. subscription,
  1051. })
  1052. const result =
  1053. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1054. expect(result).to.deep.equal({ isPaused: false })
  1055. })
  1056. it('should return isPaused: false when subscription has no subscriptionId', async function (ctx) {
  1057. const subscription = {
  1058. paymentProvider: {
  1059. service: 'stripe',
  1060. subscriptionId: null,
  1061. },
  1062. }
  1063. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1064. subscription,
  1065. })
  1066. const result =
  1067. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1068. expect(result).to.deep.equal({ isPaused: false })
  1069. })
  1070. it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function (ctx) {
  1071. const subscription = {
  1072. paymentProvider: {
  1073. service: 'stripe',
  1074. subscriptionId: 'sub-123',
  1075. },
  1076. }
  1077. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1078. subscription,
  1079. })
  1080. const paymentRecord = {
  1081. subscription: {
  1082. remainingPauseCycles: 0,
  1083. },
  1084. }
  1085. ctx.Modules.promises.hooks.fire
  1086. .withArgs('getPaymentFromRecord', subscription)
  1087. .resolves([paymentRecord])
  1088. const result =
  1089. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1090. expect(result).to.deep.equal({ isPaused: false })
  1091. })
  1092. it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function (ctx) {
  1093. const subscription = {
  1094. paymentProvider: {
  1095. service: 'stripe',
  1096. subscriptionId: 'sub-123',
  1097. },
  1098. }
  1099. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1100. subscription,
  1101. })
  1102. const paymentRecord = {
  1103. subscription: {},
  1104. }
  1105. ctx.Modules.promises.hooks.fire
  1106. .withArgs('getPaymentFromRecord', subscription)
  1107. .resolves([paymentRecord])
  1108. const result =
  1109. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1110. expect(result).to.deep.equal({ isPaused: false })
  1111. })
  1112. it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function (ctx) {
  1113. const subscription = {
  1114. paymentProvider: {
  1115. service: 'stripe',
  1116. subscriptionId: 'sub-123',
  1117. },
  1118. }
  1119. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1120. subscription,
  1121. })
  1122. const paymentRecord = {
  1123. subscription: {
  1124. remainingPauseCycles: 2,
  1125. },
  1126. }
  1127. ctx.Modules.promises.hooks.fire
  1128. .withArgs('getPaymentFromRecord', subscription)
  1129. .resolves([paymentRecord])
  1130. const result =
  1131. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1132. expect(result).to.deep.equal({
  1133. isPaused: true,
  1134. redirectPath: '/user/subscription?redirect-reason=subscription-paused',
  1135. })
  1136. })
  1137. it('should return isPaused: true when remainingPauseCycles is exactly 1', async function (ctx) {
  1138. const subscription = {
  1139. paymentProvider: {
  1140. service: 'stripe',
  1141. subscriptionId: 'sub-123',
  1142. },
  1143. }
  1144. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1145. subscription,
  1146. })
  1147. const paymentRecord = {
  1148. subscription: {
  1149. remainingPauseCycles: 1,
  1150. },
  1151. }
  1152. ctx.Modules.promises.hooks.fire
  1153. .withArgs('getPaymentFromRecord', subscription)
  1154. .resolves([paymentRecord])
  1155. const result =
  1156. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1157. expect(result).to.deep.equal({
  1158. isPaused: true,
  1159. redirectPath: '/user/subscription?redirect-reason=subscription-paused',
  1160. })
  1161. })
  1162. it('should return isPaused: false when userHasSubscription throws error', async function (ctx) {
  1163. const error = new Error('Something bad happened')
  1164. ctx.LimitationsManager.promises.userHasSubscription.rejects(error)
  1165. const result =
  1166. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1167. expect(result).to.deep.equal({ isPaused: false })
  1168. })
  1169. it('should return isPaused: false when getPaymentFromRecord throws error', async function (ctx) {
  1170. const subscription = {
  1171. paymentProvider: {
  1172. service: 'stripe',
  1173. subscriptionId: 'sub-123',
  1174. },
  1175. }
  1176. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1177. subscription,
  1178. })
  1179. const error = new Error('Something bad happened')
  1180. ctx.Modules.promises.hooks.fire
  1181. .withArgs('getPaymentFromRecord', subscription)
  1182. .rejects(error)
  1183. const result =
  1184. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1185. expect(result).to.deep.equal({ isPaused: false })
  1186. })
  1187. it('should return isPaused: false when Recurly subscription is not paused', async function (ctx) {
  1188. const subscription = {
  1189. recurlySubscription_id: 'uuid-123',
  1190. recurlyStatus: {
  1191. state: 'active',
  1192. },
  1193. }
  1194. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1195. subscription,
  1196. })
  1197. const result =
  1198. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1199. expect(result).to.deep.equal({ isPaused: false })
  1200. })
  1201. it('should return isPaused: true when Recurly subscription is paused', async function (ctx) {
  1202. const subscription = {
  1203. recurlySubscription_id: 'uuid-123',
  1204. recurlyStatus: {
  1205. state: 'paused',
  1206. },
  1207. }
  1208. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1209. subscription,
  1210. })
  1211. const result =
  1212. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1213. expect(result).to.deep.equal({
  1214. isPaused: true,
  1215. redirectPath: '/user/subscription?redirect-reason=subscription-paused',
  1216. })
  1217. })
  1218. it('should return isPaused: true when Recurly subscription has pending pause cycles', async function (ctx) {
  1219. const subscription = {
  1220. recurlySubscription_id: 'uuid-123',
  1221. recurlyStatus: {
  1222. state: 'active',
  1223. },
  1224. }
  1225. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1226. subscription,
  1227. })
  1228. const recurlySubscriptionData = {
  1229. remaining_pause_cycles: 2,
  1230. }
  1231. ctx.RecurlyWrapper.promises.getSubscription.resolves(
  1232. recurlySubscriptionData
  1233. )
  1234. const result =
  1235. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1236. expect(result).to.deep.equal({
  1237. isPaused: true,
  1238. redirectPath: '/user/subscription?redirect-reason=subscription-paused',
  1239. })
  1240. expect(
  1241. ctx.RecurlyWrapper.promises.getSubscription
  1242. ).to.have.been.calledWith('uuid-123')
  1243. })
  1244. it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function (ctx) {
  1245. const subscription = {
  1246. recurlySubscription_id: 'uuid-123',
  1247. recurlyStatus: {
  1248. state: 'active',
  1249. },
  1250. }
  1251. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1252. subscription,
  1253. })
  1254. const recurlySubscriptionData = {
  1255. remaining_pause_cycles: 0,
  1256. }
  1257. ctx.RecurlyWrapper.promises.getSubscription.resolves(
  1258. recurlySubscriptionData
  1259. )
  1260. const result =
  1261. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1262. expect(result).to.deep.equal({ isPaused: false })
  1263. })
  1264. it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function (ctx) {
  1265. const subscription = {
  1266. recurlySubscription_id: 'uuid-123',
  1267. recurlyStatus: {
  1268. state: 'active',
  1269. },
  1270. }
  1271. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1272. subscription,
  1273. })
  1274. const recurlySubscriptionData = {}
  1275. ctx.RecurlyWrapper.promises.getSubscription.resolves(
  1276. recurlySubscriptionData
  1277. )
  1278. const result =
  1279. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1280. expect(result).to.deep.equal({ isPaused: false })
  1281. })
  1282. it('should return isPaused: false when Recurly API call fails', async function (ctx) {
  1283. const subscription = {
  1284. recurlySubscription_id: 'uuid-123',
  1285. recurlyStatus: {
  1286. state: 'active',
  1287. },
  1288. }
  1289. ctx.LimitationsManager.promises.userHasSubscription.resolves({
  1290. subscription,
  1291. })
  1292. const error = new Error('Recurly API failed')
  1293. ctx.RecurlyWrapper.promises.getSubscription.rejects(error)
  1294. const result =
  1295. await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
  1296. expect(result).to.deep.equal({ isPaused: false })
  1297. })
  1298. })
  1299. describe('previewAddonPurchase', function () {
  1300. beforeEach(function (ctx) {
  1301. ctx.req = new MockRequest()
  1302. ctx.req.params = { addOnCode: 'assistant' }
  1303. ctx.req.query = { purchaseReferrer: 'fake-referrer' }
  1304. ctx.res = new MockResponse()
  1305. ctx.Modules.promises.hooks.fire
  1306. .withArgs('getPaymentMethod')
  1307. .resolves(['fake-method'])
  1308. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
  1309. })
  1310. describe('when user has manual or custom subscription', function () {
  1311. it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function (ctx) {
  1312. const customSubscription = {
  1313. _id: 'sub-123',
  1314. customAccount: true,
  1315. collectionMethod: 'automatic',
  1316. }
  1317. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1318. customSubscription
  1319. )
  1320. ctx.res.redirect = sinon.stub()
  1321. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1322. expect(ctx.res.redirect).to.have.been.calledWith(
  1323. '/user/subscription?redirect-reason=ai-assist-unavailable'
  1324. )
  1325. })
  1326. it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function (ctx) {
  1327. const manualSubscription = {
  1328. _id: 'sub-123',
  1329. customAccount: false,
  1330. collectionMethod: 'manual',
  1331. }
  1332. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1333. manualSubscription
  1334. )
  1335. ctx.res.redirect = sinon.stub()
  1336. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1337. expect(ctx.res.redirect).to.have.been.calledWith(
  1338. '/user/subscription?redirect-reason=ai-assist-unavailable'
  1339. )
  1340. })
  1341. it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function (ctx) {
  1342. const customManualSubscription = {
  1343. _id: 'sub-123',
  1344. customAccount: true,
  1345. collectionMethod: 'manual',
  1346. }
  1347. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1348. customManualSubscription
  1349. )
  1350. ctx.res.redirect = sinon.stub()
  1351. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1352. expect(ctx.res.redirect).to.have.been.calledWith(
  1353. '/user/subscription?redirect-reason=ai-assist-unavailable'
  1354. )
  1355. })
  1356. })
  1357. describe('when user has normal subscription', function () {
  1358. it('should proceed with preview when subscription is not manual or custom', async function (ctx) {
  1359. const normalSubscription = {
  1360. _id: 'sub-123',
  1361. customAccount: false,
  1362. collectionMethod: 'automatic',
  1363. }
  1364. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1365. normalSubscription
  1366. )
  1367. ctx.res.render = sinon.stub()
  1368. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1369. expect(ctx.res.render).to.have.been.calledWith(
  1370. 'subscriptions/preview-change',
  1371. sinon.match({
  1372. changePreview: sinon.match.object,
  1373. purchaseReferrer: 'fake-referrer',
  1374. redirectedPaymentErrorCode: undefined,
  1375. })
  1376. )
  1377. expect(
  1378. ctx.SubscriptionHandler.promises.previewAddonPurchase
  1379. ).to.have.been.calledWith(ctx.user._id, 'assistant')
  1380. })
  1381. it('should pass redirectedPaymentErrorCode to the view when errorCode query param is present', async function (ctx) {
  1382. const normalSubscription = {
  1383. _id: 'sub-123',
  1384. customAccount: false,
  1385. collectionMethod: 'automatic',
  1386. }
  1387. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1388. normalSubscription
  1389. )
  1390. ctx.req.query.errorCode = 'payment_failed'
  1391. ctx.res.render = sinon.stub()
  1392. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1393. expect(ctx.res.render).to.have.been.calledWith(
  1394. 'subscriptions/preview-change',
  1395. sinon.match({
  1396. changePreview: sinon.match.object,
  1397. purchaseReferrer: 'fake-referrer',
  1398. redirectedPaymentErrorCode: 'payment_failed',
  1399. })
  1400. )
  1401. })
  1402. it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function (ctx) {
  1403. const normalSubscription = {
  1404. _id: 'sub-123',
  1405. // customAccount: undefined (not set)
  1406. collectionMethod: 'automatic',
  1407. }
  1408. ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
  1409. normalSubscription
  1410. )
  1411. ctx.res.render = sinon.stub()
  1412. await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
  1413. expect(ctx.res.render).to.have.been.calledWith(
  1414. 'subscriptions/preview-change'
  1415. )
  1416. expect(
  1417. ctx.SubscriptionHandler.promises.previewAddonPurchase
  1418. ).to.have.been.calledWith(ctx.user._id, 'assistant')
  1419. })
  1420. })
  1421. })
  1422. })