SubscriptionViewModelBuilder.test.mjs 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081
  1. import { vi, assert } from 'vitest'
  2. import sinon from 'sinon'
  3. import PaymentProviderEntities from '../../../../app/src/Features/Subscription/PaymentProviderEntities.mjs'
  4. import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.mjs'
  5. const {
  6. PaymentProviderAccount,
  7. PaymentProviderSubscription,
  8. PaymentProviderSubscriptionAddOn,
  9. PaymentProviderSubscriptionChange,
  10. } = PaymentProviderEntities
  11. const modulePath =
  12. '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs'
  13. describe('SubscriptionViewModelBuilder', function () {
  14. beforeEach(async function (ctx) {
  15. ctx.user = { _id: '5208dd34438842e2db333333' }
  16. ctx.recurlySubscription_id = '123abc456def'
  17. ctx.planCode = 'collaborator_monthly'
  18. ctx.planFeatures = {
  19. compileGroup: 'priority',
  20. collaborators: -1,
  21. compileTimeout: 240,
  22. }
  23. ctx.plan = {
  24. planCode: ctx.planCode,
  25. features: ctx.planFeatures,
  26. }
  27. ctx.annualPlanCode = 'collaborator_annual'
  28. ctx.annualPlan = {
  29. planCode: ctx.annualPlanCode,
  30. features: ctx.planFeatures,
  31. }
  32. ctx.individualSubscription = {
  33. planCode: ctx.planCode,
  34. plan: ctx.plan,
  35. recurlySubscription_id: ctx.recurlySubscription_id,
  36. recurlyStatus: {
  37. state: 'active',
  38. },
  39. }
  40. ctx.paymentRecord = new PaymentProviderSubscription({
  41. id: ctx.recurlySubscription_id,
  42. userId: ctx.user._id,
  43. currency: 'EUR',
  44. planCode: 'plan-code',
  45. planName: 'plan-name',
  46. planPrice: 13,
  47. addOns: [
  48. new PaymentProviderSubscriptionAddOn({
  49. code: 'addon-code',
  50. name: 'addon name',
  51. quantity: 1,
  52. unitPrice: 2,
  53. }),
  54. ],
  55. subtotal: 15,
  56. taxRate: 0.1,
  57. taxAmount: 1.5,
  58. total: 16.5,
  59. periodStart: new Date('2025-01-20T12:00:00.000Z'),
  60. periodEnd: new Date('2025-02-20T12:00:00.000Z'),
  61. collectionMethod: 'automatic',
  62. })
  63. ctx.individualCustomSubscription = {
  64. planCode: ctx.planCode,
  65. plan: ctx.plan,
  66. recurlySubscription_id: ctx.recurlySubscription_id,
  67. }
  68. ctx.groupPlanCode = 'group_collaborator_monthly'
  69. ctx.groupPlanFeatures = {
  70. compileGroup: 'priority',
  71. collaborators: 10,
  72. compileTimeout: 240,
  73. }
  74. ctx.groupPlan = {
  75. planCode: ctx.groupPlanCode,
  76. features: ctx.groupPlanFeatures,
  77. membersLimit: 4,
  78. membersLimitAddOn: 'additional-license',
  79. groupPlan: true,
  80. }
  81. ctx.groupSubscription = {
  82. planCode: ctx.groupPlanCode,
  83. plan: ctx.plan,
  84. recurlyStatus: {
  85. state: 'active',
  86. },
  87. }
  88. ctx.commonsPlanCode = 'commons_license'
  89. ctx.commonsPlanFeatures = {
  90. compileGroup: 'priority',
  91. collaborators: '-1',
  92. compileTimeout: 240,
  93. }
  94. ctx.commonsPlan = {
  95. planCode: ctx.commonsPlanCode,
  96. features: ctx.commonsPlanFeatures,
  97. }
  98. ctx.commonsSubscription = {
  99. planCode: ctx.commonsPlanCode,
  100. plan: ctx.commonsPlan,
  101. name: 'Digital Science',
  102. }
  103. ctx.Settings = {
  104. institutionPlanCode: ctx.commonsPlanCode,
  105. }
  106. ctx.SubscriptionLocator = {
  107. promises: {
  108. getUsersSubscription: sinon.stub().resolves(),
  109. getMemberSubscriptions: sinon.stub().resolves(),
  110. getManagedGroupSubscriptions: sinon.stub().resolves([]),
  111. },
  112. getUsersSubscription: sinon.stub().yields(),
  113. getMemberSubscriptions: sinon.stub().yields(null, []),
  114. getManagedGroupSubscriptions: sinon.stub().yields(null, []),
  115. findLocalPlanInSettings: sinon.stub(),
  116. }
  117. ctx.InstitutionsGetter = {
  118. promises: {
  119. getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
  120. },
  121. getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []),
  122. getManagedInstitutions: sinon.stub().yields(null, []),
  123. }
  124. ctx.InstitutionsManager = {
  125. promises: {
  126. fetchV1Data: sinon.stub().resolves(),
  127. },
  128. }
  129. ctx.PublishersGetter = {
  130. promises: {
  131. fetchV1Data: sinon.stub().resolves(),
  132. },
  133. getManagedPublishers: sinon.stub().yields(null, []),
  134. }
  135. ctx.RecurlyWrapper = {
  136. promises: {
  137. getSubscription: sinon.stub().resolves(),
  138. },
  139. }
  140. ctx.SubscriptionUpdater = {
  141. promises: {
  142. updateSubscriptionFromRecurly: sinon.stub().resolves(),
  143. },
  144. }
  145. ctx.PlansLocator = {
  146. findLocalPlanInSettings: sinon.stub(),
  147. }
  148. ctx.SplitTestHandler = {
  149. promises: {
  150. getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
  151. },
  152. }
  153. vi.doMock('@overleaf/settings', () => ({
  154. default: ctx.Settings,
  155. }))
  156. vi.doMock(
  157. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  158. () => ({
  159. default: ctx.SubscriptionLocator,
  160. })
  161. )
  162. vi.doMock(
  163. '../../../../app/src/Features/Institutions/InstitutionsGetter',
  164. () => ({
  165. default: ctx.InstitutionsGetter,
  166. })
  167. )
  168. vi.doMock(
  169. '../../../../app/src/Features/Institutions/InstitutionsManager',
  170. () => ({
  171. default: ctx.InstitutionsManager,
  172. })
  173. )
  174. vi.doMock(
  175. '../../../../app/src/Features/Subscription/RecurlyWrapper',
  176. () => ({
  177. default: ctx.RecurlyWrapper,
  178. })
  179. )
  180. vi.doMock(
  181. '../../../../app/src/Features/Subscription/SubscriptionUpdater',
  182. () => ({
  183. default: ctx.SubscriptionUpdater,
  184. })
  185. )
  186. vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
  187. default: ctx.PlansLocator,
  188. }))
  189. vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
  190. default: (ctx.Modules = {
  191. promises: { hooks: { fire: sinon.stub().resolves([]) } },
  192. hooks: {
  193. fire: sinon.stub().yields(null, []),
  194. },
  195. }),
  196. }))
  197. vi.doMock(
  198. '../../../../app/src/Features/Subscription/V1SubscriptionManager',
  199. () => ({
  200. default: {},
  201. })
  202. )
  203. vi.doMock(
  204. '../../../../app/src/Features/Publishers/PublishersGetter',
  205. () => ({
  206. default: ctx.PublishersGetter,
  207. })
  208. )
  209. vi.doMock(
  210. '../../../../app/src/Features/Subscription/SubscriptionHelper',
  211. () => ({
  212. default: SubscriptionHelper,
  213. })
  214. )
  215. vi.doMock(
  216. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  217. () => ({
  218. default: ctx.SplitTestHandler,
  219. })
  220. )
  221. ctx.SubscriptionViewModelBuilder = (await import(modulePath)).default
  222. ctx.PlansLocator.findLocalPlanInSettings
  223. .withArgs(ctx.planCode)
  224. .returns(ctx.plan)
  225. .withArgs(ctx.annualPlanCode)
  226. .returns(ctx.annualPlan)
  227. .withArgs(ctx.groupPlanCode)
  228. .returns(ctx.groupPlan)
  229. .withArgs(ctx.commonsPlanCode)
  230. .returns(ctx.commonsPlan)
  231. })
  232. describe('getUsersSubscriptionDetails', function () {
  233. it('should return a free plan when user has no subscription or affiliation', async function (ctx) {
  234. const { bestSubscription: usersBestSubscription } =
  235. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  236. ctx.user
  237. )
  238. assert.deepEqual(usersBestSubscription, { type: 'free' })
  239. })
  240. describe('with a individual subscription only', function () {
  241. it('should return a individual subscription when user has non-Recurly one', async function (ctx) {
  242. ctx.SubscriptionLocator.promises.getUsersSubscription
  243. .withArgs(ctx.user)
  244. .resolves(ctx.individualCustomSubscription)
  245. const { bestSubscription: usersBestSubscription } =
  246. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  247. ctx.user
  248. )
  249. assert.deepEqual(usersBestSubscription, {
  250. type: 'individual',
  251. subscription: ctx.individualCustomSubscription,
  252. plan: ctx.plan,
  253. remainingTrialDays: -1,
  254. })
  255. })
  256. it('should return a individual subscription when user has an active one', async function (ctx) {
  257. ctx.SubscriptionLocator.promises.getUsersSubscription
  258. .withArgs(ctx.user)
  259. .resolves(ctx.individualSubscription)
  260. const { bestSubscription: usersBestSubscription } =
  261. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  262. ctx.user
  263. )
  264. assert.deepEqual(usersBestSubscription, {
  265. type: 'individual',
  266. subscription: ctx.individualSubscription,
  267. plan: ctx.plan,
  268. remainingTrialDays: -1,
  269. })
  270. })
  271. it('should return a individual subscription with remaining free trial days', async function (ctx) {
  272. const threeDaysLater = new Date()
  273. threeDaysLater.setDate(threeDaysLater.getDate() + 3)
  274. ctx.individualSubscription.recurlyStatus.trialEndsAt = threeDaysLater
  275. ctx.SubscriptionLocator.promises.getUsersSubscription
  276. .withArgs(ctx.user)
  277. .resolves(ctx.individualSubscription)
  278. const { bestSubscription: usersBestSubscription } =
  279. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  280. ctx.user
  281. )
  282. assert.deepEqual(usersBestSubscription, {
  283. type: 'individual',
  284. subscription: ctx.individualSubscription,
  285. plan: ctx.plan,
  286. remainingTrialDays: 3,
  287. })
  288. })
  289. it('should return a individual subscription with free trial on last day', async function (ctx) {
  290. const threeHoursLater = new Date()
  291. threeHoursLater.setTime(threeHoursLater.getTime() + 3 * 60 * 60 * 1000)
  292. ctx.individualSubscription.recurlyStatus.trialEndsAt = threeHoursLater
  293. ctx.SubscriptionLocator.promises.getUsersSubscription
  294. .withArgs(ctx.user)
  295. .resolves(ctx.individualSubscription)
  296. const { bestSubscription: usersBestSubscription } =
  297. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  298. ctx.user
  299. )
  300. assert.deepEqual(usersBestSubscription, {
  301. type: 'individual',
  302. subscription: ctx.individualSubscription,
  303. plan: ctx.plan,
  304. remainingTrialDays: 1,
  305. })
  306. })
  307. it('should update subscription if recurly payment state is missing', async function (ctx) {
  308. ctx.individualSubscriptionWithoutPaymentState = {
  309. planCode: ctx.planCode,
  310. plan: ctx.plan,
  311. recurlySubscription_id: ctx.recurlySubscription_id,
  312. }
  313. ctx.paymentRecord = {
  314. state: 'active',
  315. }
  316. ctx.SubscriptionLocator.promises.getUsersSubscription
  317. .withArgs(ctx.user)
  318. .onCall(0)
  319. .resolves(ctx.individualSubscriptionWithoutPaymentState)
  320. .withArgs(ctx.user)
  321. .onCall(1)
  322. .resolves(ctx.individualSubscription)
  323. const payment = {
  324. subscription: ctx.paymentRecord,
  325. account: new PaymentProviderAccount({}),
  326. coupons: [],
  327. }
  328. ctx.Modules.promises.hooks.fire
  329. .withArgs(
  330. 'getPaymentFromRecordPromise',
  331. ctx.individualSubscriptionWithoutPaymentState
  332. )
  333. .resolves([payment])
  334. ctx.Modules.promises.hooks.fire
  335. .withArgs(
  336. 'syncSubscription',
  337. payment,
  338. ctx.individualSubscriptionWithoutPaymentState
  339. )
  340. .resolves([])
  341. const { bestSubscription: usersBestSubscription } =
  342. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  343. ctx.user
  344. )
  345. assert.deepEqual(usersBestSubscription, {
  346. type: 'individual',
  347. subscription: ctx.individualSubscription,
  348. plan: ctx.plan,
  349. remainingTrialDays: -1,
  350. })
  351. assert.isTrue(
  352. ctx.Modules.promises.hooks.fire.withArgs(
  353. 'getPaymentFromRecordPromise',
  354. ctx.individualSubscriptionWithoutPaymentState
  355. ).calledOnce
  356. )
  357. })
  358. it('should update subscription if stripe payment state is missing', async function (ctx) {
  359. ctx.individualSubscriptionWithoutPaymentState = {
  360. planCode: ctx.planCode,
  361. plan: ctx.plan,
  362. paymentProvider: {
  363. subscriptionId: ctx.recurlySubscription_id,
  364. },
  365. }
  366. ctx.paymentRecord = {
  367. state: 'active',
  368. }
  369. ctx.SubscriptionLocator.promises.getUsersSubscription
  370. .withArgs(ctx.user)
  371. .onCall(0)
  372. .resolves(ctx.individualSubscriptionWithoutPaymentState)
  373. .withArgs(ctx.user)
  374. .onCall(1)
  375. .resolves(ctx.individualSubscription)
  376. const payment = {
  377. subscription: ctx.paymentRecord,
  378. account: new PaymentProviderAccount({}),
  379. coupons: [],
  380. }
  381. ctx.Modules.promises.hooks.fire
  382. .withArgs(
  383. 'getPaymentFromRecordPromise',
  384. ctx.individualSubscriptionWithoutPaymentState
  385. )
  386. .resolves([payment])
  387. ctx.Modules.promises.hooks.fire
  388. .withArgs(
  389. 'syncSubscription',
  390. payment,
  391. ctx.individualSubscriptionWithoutPaymentState
  392. )
  393. .resolves([])
  394. const { bestSubscription: usersBestSubscription } =
  395. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  396. ctx.user
  397. )
  398. assert.deepEqual(usersBestSubscription, {
  399. type: 'individual',
  400. subscription: ctx.individualSubscription,
  401. plan: ctx.plan,
  402. remainingTrialDays: -1,
  403. })
  404. assert.isTrue(
  405. ctx.Modules.promises.hooks.fire.withArgs(
  406. 'getPaymentFromRecordPromise',
  407. ctx.individualSubscriptionWithoutPaymentState
  408. ).calledOnce
  409. )
  410. })
  411. })
  412. it('should return a group subscription when user has one', async function (ctx) {
  413. ctx.SubscriptionLocator.promises.getMemberSubscriptions
  414. .withArgs(ctx.user)
  415. .resolves([ctx.groupSubscription])
  416. const { bestSubscription: usersBestSubscription } =
  417. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  418. ctx.user
  419. )
  420. assert.deepEqual(usersBestSubscription, {
  421. type: 'group',
  422. subscription: {},
  423. plan: ctx.groupPlan,
  424. remainingTrialDays: -1,
  425. })
  426. })
  427. it('should return a group subscription with team name when user has one', async function (ctx) {
  428. ctx.SubscriptionLocator.promises.getMemberSubscriptions
  429. .withArgs(ctx.user)
  430. .resolves([
  431. Object.assign({}, ctx.groupSubscription, { teamName: 'test team' }),
  432. ])
  433. const { bestSubscription: usersBestSubscription } =
  434. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  435. ctx.user
  436. )
  437. assert.deepEqual(usersBestSubscription, {
  438. type: 'group',
  439. subscription: { teamName: 'test team' },
  440. plan: ctx.groupPlan,
  441. remainingTrialDays: -1,
  442. })
  443. })
  444. it('should return a commons subscription when user has an institution affiliation', async function (ctx) {
  445. ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
  446. .withArgs(ctx.user._id)
  447. .resolves([ctx.commonsSubscription])
  448. const { bestSubscription: usersBestSubscription } =
  449. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  450. ctx.user
  451. )
  452. assert.deepEqual(usersBestSubscription, {
  453. type: 'commons',
  454. subscription: ctx.commonsSubscription,
  455. plan: ctx.commonsPlan,
  456. })
  457. })
  458. describe('with multiple subscriptions', function () {
  459. beforeEach(function (ctx) {
  460. ctx.SubscriptionLocator.promises.getUsersSubscription
  461. .withArgs(ctx.user)
  462. .resolves(ctx.individualSubscription)
  463. ctx.SubscriptionLocator.promises.getMemberSubscriptions
  464. .withArgs(ctx.user)
  465. .resolves([ctx.groupSubscription])
  466. ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
  467. .withArgs(ctx.user._id)
  468. .resolves([ctx.commonsSubscription])
  469. })
  470. it('should return individual when the individual subscription has the best feature set', async function (ctx) {
  471. ctx.commonsPlan.features = {
  472. compileGroup: 'standard',
  473. collaborators: 1,
  474. compileTimeout: 60,
  475. }
  476. const { bestSubscription: usersBestSubscription } =
  477. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  478. ctx.user
  479. )
  480. assert.deepEqual(usersBestSubscription, {
  481. type: 'individual',
  482. subscription: ctx.individualSubscription,
  483. plan: ctx.plan,
  484. remainingTrialDays: -1,
  485. })
  486. })
  487. it('should return group when the group subscription has the best feature set', async function (ctx) {
  488. ctx.plan.features = {
  489. compileGroup: 'standard',
  490. collaborators: 1,
  491. compileTimeout: 60,
  492. }
  493. ctx.commonsPlan.features = {
  494. compileGroup: 'standard',
  495. collaborators: 1,
  496. compileTimeout: 60,
  497. }
  498. const { bestSubscription: usersBestSubscription } =
  499. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  500. ctx.user
  501. )
  502. assert.deepEqual(usersBestSubscription, {
  503. type: 'group',
  504. subscription: {},
  505. plan: ctx.groupPlan,
  506. remainingTrialDays: -1,
  507. })
  508. })
  509. it('should return commons when the commons affiliation has the best feature set', async function (ctx) {
  510. ctx.plan.features = {
  511. compileGroup: 'priority',
  512. collaborators: 5,
  513. compileTimeout: 240,
  514. }
  515. ctx.groupPlan.features = {
  516. compileGroup: 'standard',
  517. collaborators: 1,
  518. compileTimeout: 60,
  519. }
  520. ctx.commonsPlan.features = {
  521. compileGroup: 'priority',
  522. collaborators: -1,
  523. compileTimeout: 240,
  524. }
  525. const { bestSubscription: usersBestSubscription } =
  526. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  527. ctx.user
  528. )
  529. assert.deepEqual(usersBestSubscription, {
  530. type: 'commons',
  531. subscription: ctx.commonsSubscription,
  532. plan: ctx.commonsPlan,
  533. })
  534. })
  535. it('should return individual with equal feature sets', async function (ctx) {
  536. ctx.plan.features = {
  537. compileGroup: 'priority',
  538. collaborators: -1,
  539. compileTimeout: 240,
  540. }
  541. ctx.groupPlan.features = {
  542. compileGroup: 'priority',
  543. collaborators: -1,
  544. compileTimeout: 240,
  545. }
  546. ctx.commonsPlan.features = {
  547. compileGroup: 'priority',
  548. collaborators: -1,
  549. compileTimeout: 240,
  550. }
  551. const { bestSubscription: usersBestSubscription } =
  552. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  553. ctx.user
  554. )
  555. assert.deepEqual(usersBestSubscription, {
  556. type: 'individual',
  557. subscription: ctx.individualSubscription,
  558. plan: ctx.plan,
  559. remainingTrialDays: -1,
  560. })
  561. })
  562. it('should return group over commons with equal feature sets', async function (ctx) {
  563. ctx.plan.features = {
  564. compileGroup: 'standard',
  565. collaborators: 1,
  566. compileTimeout: 60,
  567. }
  568. ctx.groupPlan.features = {
  569. compileGroup: 'priority',
  570. collaborators: -1,
  571. compileTimeout: 240,
  572. }
  573. ctx.commonsPlan.features = {
  574. compileGroup: 'priority',
  575. collaborators: -1,
  576. compileTimeout: 240,
  577. }
  578. const { bestSubscription: usersBestSubscription } =
  579. await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  580. ctx.user
  581. )
  582. assert.deepEqual(usersBestSubscription, {
  583. type: 'group',
  584. subscription: {},
  585. plan: ctx.groupPlan,
  586. remainingTrialDays: -1,
  587. })
  588. })
  589. })
  590. })
  591. describe('buildUsersSubscriptionViewModel', function () {
  592. beforeEach(function (ctx) {
  593. ctx.SubscriptionLocator.getUsersSubscription.yields(
  594. null,
  595. ctx.individualSubscription
  596. )
  597. ctx.Modules.hooks.fire
  598. .withArgs('getPaymentFromRecord', ctx.individualSubscription)
  599. .yields(null, [
  600. {
  601. subscription: ctx.paymentRecord,
  602. account: new PaymentProviderAccount({}),
  603. coupons: [],
  604. },
  605. ])
  606. })
  607. describe('with a paid subscription', function () {
  608. it('adds payment data to the personal subscription', async function (ctx) {
  609. ctx.Modules.hooks.fire
  610. .withArgs('getPaymentFromRecord', ctx.individualSubscription)
  611. .yields(null, [
  612. {
  613. subscription: ctx.paymentRecord,
  614. account: new PaymentProviderAccount({
  615. email: 'example@example.com',
  616. hasPastDueInvoice: false,
  617. }),
  618. coupons: [],
  619. },
  620. ])
  621. const result =
  622. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  623. ctx.user
  624. )
  625. assert.deepEqual(result.personalSubscription.payment, {
  626. taxRate: 0.1,
  627. billingDetailsLink: '/user/subscription/payment/billing-details',
  628. accountManagementLink:
  629. '/user/subscription/payment/account-management',
  630. additionalLicenses: 0,
  631. addOns: [
  632. {
  633. code: 'addon-code',
  634. name: 'addon name',
  635. quantity: 1,
  636. unitPrice: 2,
  637. preTaxTotal: 2,
  638. },
  639. ],
  640. totalLicenses: 0,
  641. nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC',
  642. nextPaymentDueDate: 'February 20th, 2025',
  643. currency: 'EUR',
  644. state: 'active',
  645. trialEndsAtFormatted: null,
  646. trialEndsAt: null,
  647. activeCoupons: [],
  648. accountEmail: 'example@example.com',
  649. hasPastDueInvoice: false,
  650. pausedAt: null,
  651. remainingPauseCycles: null,
  652. displayPrice: '€16.50',
  653. planOnlyDisplayPrice: '€14.30',
  654. addOnDisplayPricesWithoutAdditionalLicense: {
  655. 'addon-code': '€2.20',
  656. },
  657. isEligibleForGroupPlan: true,
  658. isEligibleForPause: false,
  659. isEligibleForDowngradeUpsell: true,
  660. })
  661. })
  662. it('filters out single-use coupons for stripe subscriptions', async function (ctx) {
  663. ctx.paymentRecord.service = 'stripe-us'
  664. const foreverCoupon = {
  665. code: 'forever',
  666. name: 'Forever',
  667. isSingleUse: false,
  668. }
  669. const singleUseCoupon = {
  670. code: 'once',
  671. name: 'Once',
  672. isSingleUse: true,
  673. }
  674. ctx.Modules.hooks.fire
  675. .withArgs('getPaymentFromRecord', ctx.individualSubscription)
  676. .yields(null, [
  677. {
  678. subscription: ctx.paymentRecord,
  679. account: new PaymentProviderAccount({
  680. email: 'example@example.com',
  681. hasPastDueInvoice: false,
  682. }),
  683. coupons: [foreverCoupon, singleUseCoupon],
  684. },
  685. ])
  686. const result =
  687. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  688. ctx.user
  689. )
  690. assert.deepEqual(result.personalSubscription.payment.activeCoupons, [
  691. foreverCoupon,
  692. ])
  693. })
  694. describe('isEligibleForGroupPlan', function () {
  695. it('is false when in trial', async function (ctx) {
  696. const msIn24Hours = 24 * 60 * 60 * 1000
  697. const tomorrow = new Date(Date.now() + msIn24Hours)
  698. ctx.paymentRecord.trialPeriodEnd = tomorrow
  699. ctx.paymentRecord.service = 'recurly'
  700. const result =
  701. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  702. ctx.user
  703. )
  704. assert.isFalse(
  705. result.personalSubscription.payment.isEligibleForGroupPlan
  706. )
  707. })
  708. it('is true when not in trial', async function (ctx) {
  709. const result =
  710. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  711. ctx.user
  712. )
  713. assert.isTrue(
  714. result.personalSubscription.payment.isEligibleForGroupPlan
  715. )
  716. })
  717. })
  718. describe('isEligibleForPause', function () {
  719. beforeEach(function (ctx) {
  720. ctx.paymentRecord.service = 'recurly'
  721. ctx.paymentRecord.addOns = []
  722. ctx.paymentRecord.planCode = 'plan-code'
  723. ctx.paymentRecord.trialPeriodEnd = null
  724. ctx.individualSubscription.pendingPlan = undefined
  725. ctx.individualSubscription.groupPlan = undefined
  726. })
  727. it('is false for Stripe subscriptions when feature flag is disabled', async function (ctx) {
  728. ctx.paymentRecord.service = 'stripe-us'
  729. ctx.SplitTestHandler.promises.getAssignmentForUser
  730. .withArgs(ctx.user._id, 'stripe-pause')
  731. .resolves({ variant: 'default' })
  732. const result =
  733. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  734. ctx.user
  735. )
  736. assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
  737. })
  738. it('is true for Stripe subscriptions when feature flag is enabled', async function (ctx) {
  739. ctx.paymentRecord.service = 'stripe-us'
  740. ctx.SplitTestHandler.promises.getAssignmentForUser
  741. .withArgs(ctx.user._id, 'stripe-pause')
  742. .resolves({ variant: 'enabled' })
  743. const result =
  744. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  745. ctx.user
  746. )
  747. assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
  748. })
  749. it('is false for Stripe subscriptions with pending plan even when feature flag is enabled', async function (ctx) {
  750. ctx.paymentRecord.service = 'stripe-us'
  751. ctx.individualSubscription.pendingPlan = {} // anything
  752. ctx.SplitTestHandler.promises.getAssignmentForUser
  753. .withArgs(ctx.user._id, 'stripe-pause')
  754. .resolves({ variant: 'enabled' })
  755. const result =
  756. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  757. ctx.user
  758. )
  759. assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
  760. })
  761. it('is false for Stripe subscriptions with annual plan even when feature flag is enabled', async function (ctx) {
  762. ctx.paymentRecord.service = 'stripe-us'
  763. ctx.paymentRecord.planCode = 'collaborator-annual'
  764. ctx.SplitTestHandler.promises.getAssignmentForUser
  765. .withArgs(ctx.user._id, 'stripe-pause')
  766. .resolves({ variant: 'enabled' })
  767. const result =
  768. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  769. ctx.user
  770. )
  771. assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
  772. })
  773. it('is false for subscriptions with pending plan', async function (ctx) {
  774. ctx.paymentRecord.service = 'recurly'
  775. ctx.individualSubscription.pendingPlan = {} // anything
  776. const result =
  777. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  778. ctx.user
  779. )
  780. assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
  781. })
  782. it('is false for a group subscription', async function (ctx) {
  783. ctx.paymentRecord.service = 'recurly'
  784. ctx.individualSubscription.groupPlan = true
  785. const result =
  786. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  787. ctx.user
  788. )
  789. assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
  790. })
  791. it('is false when in trial', async function (ctx) {
  792. ctx.paymentRecord.service = 'recurly'
  793. const msIn24Hours = 24 * 60 * 60 * 1000
  794. const tomorrow = new Date(Date.now() + msIn24Hours)
  795. ctx.paymentRecord.trialPeriodEnd = tomorrow
  796. const result =
  797. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  798. ctx.user
  799. )
  800. assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
  801. })
  802. it('is false for annual subscriptions', async function (ctx) {
  803. ctx.paymentRecord.service = 'recurly'
  804. ctx.paymentRecord.planCode = 'collaborator-annual'
  805. const result =
  806. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  807. ctx.user
  808. )
  809. assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
  810. })
  811. it('is false for subscriptions with add-ons', async function (ctx) {
  812. ctx.paymentRecord.service = 'recurly'
  813. ctx.paymentRecord.addOns = [{}] // anything
  814. const result =
  815. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  816. ctx.user
  817. )
  818. assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
  819. })
  820. it('is true when conditions are met', async function (ctx) {
  821. ctx.paymentRecord.service = 'recurly'
  822. ctx.paymentRecord.addOns = []
  823. const result =
  824. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  825. ctx.user
  826. )
  827. assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
  828. })
  829. })
  830. describe('isEligibleForDowngradeUpsell', function () {
  831. it('is true for eligible individual subscriptions', async function (ctx) {
  832. ctx.paymentRecord.pausePeriodStart = null
  833. ctx.paymentRecord.remainingPauseCycles = null
  834. ctx.paymentRecord.trialPeriodEnd = null
  835. ctx.paymentRecord.service = 'recurly'
  836. const result =
  837. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  838. ctx.user
  839. )
  840. assert.isTrue(
  841. result.personalSubscription.payment.isEligibleForDowngradeUpsell
  842. )
  843. })
  844. it('is false for group plans', async function (ctx) {
  845. ctx.individualSubscription.planCode = ctx.groupPlanCode
  846. ctx.paymentRecord.pausePeriodStart = null
  847. ctx.paymentRecord.remainingPauseCycles = null
  848. ctx.paymentRecord.trialPeriodEnd = null
  849. ctx.paymentRecord.service = 'recurly'
  850. const result =
  851. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  852. ctx.user
  853. )
  854. assert.isFalse(
  855. result.personalSubscription.payment.isEligibleForDowngradeUpsell
  856. )
  857. })
  858. it('is false for annual individual plans', async function (ctx) {
  859. ctx.individualSubscription.planCode = ctx.annualPlanCode
  860. ctx.paymentRecord.pausePeriodStart = null
  861. ctx.paymentRecord.remainingPauseCycles = null
  862. ctx.paymentRecord.trialPeriodEnd = null
  863. ctx.paymentRecord.service = 'recurly'
  864. const result =
  865. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  866. ctx.user
  867. )
  868. assert.isFalse(
  869. result.personalSubscription.payment.isEligibleForDowngradeUpsell
  870. )
  871. })
  872. it('is false for paused plans', async function (ctx) {
  873. ctx.paymentRecord.pausePeriodStart = new Date()
  874. ctx.paymentRecord.remainingPauseCycles = 1
  875. ctx.paymentRecord.trialPeriodEnd = null
  876. ctx.paymentRecord.service = 'recurly'
  877. const result =
  878. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  879. ctx.user
  880. )
  881. assert.isFalse(
  882. result.personalSubscription.payment.isEligibleForDowngradeUpsell
  883. )
  884. })
  885. it('is false for plans in free trial period', async function (ctx) {
  886. ctx.paymentRecord.pausePeriodStart = null
  887. ctx.paymentRecord.remainingPauseCycles = null
  888. ctx.paymentRecord.trialPeriodEnd = new Date(
  889. Date.now() + 24 * 60 * 60 * 1000 // tomorrow
  890. )
  891. ctx.paymentRecord.service = 'recurly'
  892. const result =
  893. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  894. ctx.user
  895. )
  896. assert.isFalse(
  897. result.personalSubscription.payment.isEligibleForDowngradeUpsell
  898. )
  899. })
  900. it('is false for Stripe subscriptions', async function (ctx) {
  901. ctx.paymentRecord.pausePeriodStart = null
  902. ctx.paymentRecord.remainingPauseCycles = null
  903. ctx.paymentRecord.trialPeriodEnd = null
  904. ctx.paymentRecord.service = 'stripe-us'
  905. const result =
  906. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  907. ctx.user
  908. )
  909. assert.isFalse(
  910. result.personalSubscription.payment.isEligibleForDowngradeUpsell
  911. )
  912. })
  913. })
  914. it('includes pending changes', async function (ctx) {
  915. ctx.paymentRecord.pendingChange = new PaymentProviderSubscriptionChange(
  916. {
  917. subscription: ctx.paymentRecord,
  918. nextPlanCode: ctx.groupPlanCode,
  919. nextPlanName: 'Group Collaborator (Annual) 4 licenses',
  920. nextPlanPrice: 1400,
  921. nextAddOns: [
  922. new PaymentProviderSubscriptionAddOn({
  923. code: 'additional-license',
  924. name: 'additional license',
  925. quantity: 8,
  926. unitPrice: 24.4,
  927. }),
  928. new PaymentProviderSubscriptionAddOn({
  929. code: 'addon-code',
  930. name: 'addon name',
  931. quantity: 1,
  932. unitPrice: 2,
  933. }),
  934. ],
  935. }
  936. )
  937. ctx.Modules.hooks.fire
  938. .withArgs('getPaymentFromRecord', ctx.individualSubscription)
  939. .yields(null, [
  940. {
  941. subscription: ctx.paymentRecord,
  942. account: {},
  943. coupons: [],
  944. },
  945. ])
  946. const result =
  947. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  948. ctx.user
  949. )
  950. assert.equal(result.personalSubscription.payment.displayPrice, '€16.50')
  951. assert.equal(
  952. result.personalSubscription.payment.planOnlyDisplayPrice,
  953. '€14.30'
  954. )
  955. assert.deepEqual(
  956. result.personalSubscription.payment
  957. .addOnDisplayPricesWithoutAdditionalLicense,
  958. { 'addon-code': '€2.20' }
  959. )
  960. assert.equal(
  961. result.personalSubscription.payment.pendingAdditionalLicenses,
  962. 8
  963. )
  964. assert.equal(
  965. result.personalSubscription.payment.pendingTotalLicenses,
  966. 12
  967. )
  968. })
  969. it('does not add a billing details link for a Stripe subscription', async function (ctx) {
  970. ctx.paymentRecord.service = 'stripe-us'
  971. ctx.Modules.hooks.fire
  972. .withArgs('getPaymentFromRecord', ctx.individualSubscription)
  973. .yields(null, [
  974. {
  975. subscription: ctx.paymentRecord,
  976. account: new PaymentProviderAccount({}),
  977. coupons: [],
  978. },
  979. ])
  980. const result =
  981. await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  982. ctx.user
  983. )
  984. assert.equal(
  985. result.personalSubscription.payment.billingDetailsLink,
  986. undefined
  987. )
  988. assert.equal(
  989. result.personalSubscription.payment.accountManagementLink,
  990. '/user/subscription/payment/account-management'
  991. )
  992. })
  993. })
  994. })
  995. })