SubscriptionController.test.mjs 48 KB

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