SubscriptionViewModelBuilder.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. // ts-check
  2. const Settings = require('@overleaf/settings')
  3. const PlansLocator = require('./PlansLocator')
  4. const { isStandaloneAiAddOnPlanCode } = require('./AiHelper')
  5. const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities')
  6. const SubscriptionFormatters = require('./SubscriptionFormatters')
  7. const SubscriptionLocator = require('./SubscriptionLocator')
  8. const InstitutionsGetter = require('../Institutions/InstitutionsGetter')
  9. const InstitutionsManager = require('../Institutions/InstitutionsManager')
  10. const PublishersGetter = require('../Publishers/PublishersGetter')
  11. const sanitizeHtml = require('sanitize-html')
  12. const _ = require('lodash')
  13. const async = require('async')
  14. const SubscriptionHelper = require('./SubscriptionHelper')
  15. const { callbackify } = require('@overleaf/promise-utils')
  16. const { V1ConnectionError } = require('../Errors/Errors')
  17. const FeaturesHelper = require('./FeaturesHelper')
  18. const { formatCurrency } = require('../../util/currency')
  19. const Modules = require('../../infrastructure/Modules')
  20. /**
  21. * @import { Subscription } from "../../../../types/project/dashboard/subscription"
  22. * @import { Subscription as DBSubscription } from "../../models/Subscription"
  23. */
  24. function buildHostedLink(type) {
  25. return `/user/subscription/payment/${type}`
  26. }
  27. // Downgrade from Mongoose object, so we can add custom attributes to object
  28. function serializeMongooseObject(object) {
  29. return object && typeof object.toObject === 'function'
  30. ? object.toObject()
  31. : object
  32. }
  33. async function buildUsersSubscriptionViewModel(user, locale = 'en') {
  34. let {
  35. personalSubscription,
  36. memberGroupSubscriptions,
  37. managedGroupSubscriptions,
  38. currentInstitutionsWithLicence,
  39. managedInstitutions,
  40. managedPublishers,
  41. fetchedPaymentRecord,
  42. plan,
  43. } = await async.auto({
  44. personalSubscription(cb) {
  45. SubscriptionLocator.getUsersSubscription(user, cb)
  46. },
  47. fetchedPaymentRecord: [
  48. 'personalSubscription',
  49. ({ personalSubscription }, cb) => {
  50. Modules.hooks.fire('getPaymentFromRecord', personalSubscription, cb)
  51. },
  52. ],
  53. plan: [
  54. 'personalSubscription',
  55. ({ personalSubscription }, cb) => {
  56. if (personalSubscription == null) {
  57. return cb()
  58. }
  59. const plan = PlansLocator.findLocalPlanInSettings(
  60. personalSubscription.planCode
  61. )
  62. if (plan == null) {
  63. return cb(
  64. new Error(
  65. `No plan found for planCode '${personalSubscription.planCode}'`
  66. )
  67. )
  68. }
  69. cb(null, plan)
  70. },
  71. ],
  72. memberGroupSubscriptions(cb) {
  73. SubscriptionLocator.getMemberSubscriptions(user, cb)
  74. },
  75. managedGroupSubscriptions(cb) {
  76. SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
  77. },
  78. currentInstitutionsWithLicence(cb) {
  79. InstitutionsGetter.getCurrentInstitutionsWithLicence(
  80. user._id,
  81. (error, institutions) => {
  82. if (error instanceof V1ConnectionError) {
  83. return cb(null, false)
  84. }
  85. cb(null, institutions)
  86. }
  87. )
  88. },
  89. managedInstitutions(cb) {
  90. InstitutionsGetter.getManagedInstitutions(user._id, cb)
  91. },
  92. managedPublishers(cb) {
  93. PublishersGetter.getManagedPublishers(user._id, cb)
  94. },
  95. })
  96. const paymentRecord = fetchedPaymentRecord && fetchedPaymentRecord[0]
  97. if (memberGroupSubscriptions == null) {
  98. memberGroupSubscriptions = []
  99. } else {
  100. memberGroupSubscriptions = memberGroupSubscriptions.map(group => {
  101. const userIsGroupManager = group.manager_ids?.some(
  102. id => id.toString() === user._id.toString()
  103. )
  104. const groupDataForView = {
  105. _id: group._id,
  106. planCode: group.planCode,
  107. teamName: group.teamName,
  108. admin_id: {
  109. email: group.admin_id.email,
  110. },
  111. userIsGroupManager,
  112. }
  113. if (group.teamNotice) {
  114. groupDataForView.teamNotice = sanitizeHtml(group.teamNotice)
  115. }
  116. buildGroupSubscriptionForView(groupDataForView)
  117. return groupDataForView
  118. })
  119. }
  120. if (managedGroupSubscriptions == null) {
  121. managedGroupSubscriptions = []
  122. } else {
  123. managedGroupSubscriptions = managedGroupSubscriptions.map(group => {
  124. const userIsGroupMember = group.member_ids?.some(
  125. id => id.toString() === user._id.toString()
  126. )
  127. const groupDataForView = {
  128. _id: group._id,
  129. planCode: group.planCode,
  130. groupPlan: group.groupPlan,
  131. teamName: group.teamName,
  132. admin_id: {
  133. _id: group.admin_id._id,
  134. email: group.admin_id.email,
  135. },
  136. features: group.features,
  137. userIsGroupMember,
  138. }
  139. buildGroupSubscriptionForView(groupDataForView)
  140. return groupDataForView
  141. })
  142. }
  143. if (managedInstitutions == null) {
  144. managedInstitutions = []
  145. }
  146. personalSubscription = serializeMongooseObject(personalSubscription)
  147. managedInstitutions = managedInstitutions.map(serializeMongooseObject)
  148. await Promise.all(
  149. managedInstitutions.map(InstitutionsManager.promises.fetchV1Data)
  150. )
  151. managedPublishers = managedPublishers.map(serializeMongooseObject)
  152. await Promise.all(
  153. managedPublishers.map(PublishersGetter.promises.fetchV1Data)
  154. )
  155. if (plan != null) {
  156. personalSubscription.plan = plan
  157. }
  158. function getPlanOnlyDisplayPrice(
  159. totalPlanPriceInCents,
  160. taxRate,
  161. addOns = []
  162. ) {
  163. // The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model
  164. const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce(
  165. (prev, curr) => {
  166. return curr.code !== MEMBERS_LIMIT_ADD_ON_CODE
  167. ? curr.quantity * curr.unitPrice + prev
  168. : prev
  169. },
  170. 0
  171. )
  172. const allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice =
  173. allAddOnsPriceInCentsExceptAdditionalLicensePrice +
  174. allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate
  175. return formatCurrency(
  176. totalPlanPriceInCents -
  177. allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice,
  178. paymentRecord.subscription.currency,
  179. locale
  180. )
  181. }
  182. function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) {
  183. return addOns.reduce((prev, curr) => {
  184. if (curr.code !== MEMBERS_LIMIT_ADD_ON_CODE) {
  185. const priceInCents = curr.quantity * curr.unitPrice
  186. const totalPriceInCents = priceInCents + priceInCents * taxRate
  187. if (totalPriceInCents > 0) {
  188. prev[curr.code] = formatCurrency(
  189. totalPriceInCents,
  190. paymentRecord.subscription.currency,
  191. locale
  192. )
  193. }
  194. }
  195. return prev
  196. }, {})
  197. }
  198. if (personalSubscription && paymentRecord && paymentRecord.subscription) {
  199. // don't return subscription payment information
  200. delete personalSubscription.paymentProvider
  201. delete personalSubscription.recurly
  202. delete personalSubscription.recurlySubscription_id
  203. const tax = paymentRecord.subscription.taxAmount || 0
  204. // Some plans allow adding more seats than the base plan provides.
  205. // This is recorded as a subscription add on.
  206. // Note: taxAmount already includes the tax for any addon.
  207. let addOnPrice = 0
  208. let additionalLicenses = 0
  209. const addOns = paymentRecord.subscription.addOns || []
  210. const taxRate = paymentRecord.subscription.taxRate
  211. addOns.forEach(addOn => {
  212. addOnPrice += addOn.quantity * addOn.unitPrice
  213. if (addOn.code === plan.membersLimitAddOn) {
  214. additionalLicenses += addOn.quantity
  215. }
  216. })
  217. const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
  218. const isInTrial =
  219. paymentRecord.subscription.trialPeriodEnd &&
  220. paymentRecord.subscription.trialPeriodEnd.getTime() > Date.now()
  221. personalSubscription.payment = {
  222. taxRate,
  223. billingDetailsLink:
  224. paymentRecord.subscription.service === 'recurly'
  225. ? buildHostedLink('billing-details')
  226. : null,
  227. accountManagementLink: buildHostedLink('account-management'),
  228. additionalLicenses,
  229. addOns,
  230. totalLicenses,
  231. nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
  232. paymentRecord.subscription.periodEnd
  233. ),
  234. nextPaymentDueDate: SubscriptionFormatters.formatDate(
  235. paymentRecord.subscription.periodEnd
  236. ),
  237. currency: paymentRecord.subscription.currency,
  238. state: paymentRecord.subscription.state,
  239. trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
  240. paymentRecord.subscription.trialPeriodEnd
  241. ),
  242. trialEndsAt: paymentRecord.subscription.trialPeriodEnd,
  243. activeCoupons: paymentRecord.coupons,
  244. accountEmail: paymentRecord.account.email,
  245. hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice,
  246. pausedAt: paymentRecord.subscription.pausePeriodStart,
  247. remainingPauseCycles: paymentRecord.subscription.remainingPauseCycles,
  248. isEligibleForPause:
  249. paymentRecord.subscription.service === 'recurly' &&
  250. !personalSubscription.pendingPlan &&
  251. !personalSubscription.groupPlan &&
  252. !isInTrial &&
  253. !paymentRecord.subscription.planCode.includes('ann') &&
  254. !paymentRecord.subscription.addOns?.length > 0,
  255. isEligibleForGroupPlan:
  256. paymentRecord.subscription.service === 'recurly' && !isInTrial,
  257. }
  258. const isMonthlyCollaboratorPlan =
  259. personalSubscription.planCode.includes('collaborator') &&
  260. !personalSubscription.planCode.includes('ann') &&
  261. !personalSubscription.plan.groupPlan
  262. personalSubscription.payment.isEligibleForDowngradeUpsell =
  263. !personalSubscription.payment.pausedAt &&
  264. !personalSubscription.payment.remainingPauseCycles &&
  265. isMonthlyCollaboratorPlan &&
  266. !isInTrial &&
  267. paymentRecord.subscription.service === 'recurly'
  268. if (paymentRecord.subscription.pendingChange) {
  269. const pendingPlanCode =
  270. paymentRecord.subscription.pendingChange.nextPlanCode
  271. const pendingPlan = PlansLocator.findLocalPlanInSettings(pendingPlanCode)
  272. if (pendingPlan == null) {
  273. throw new Error(`No plan found for planCode '${pendingPlanCode}'`)
  274. }
  275. let pendingAdditionalLicenses = 0
  276. let pendingAddOnTax = 0
  277. let pendingAddOnPrice = 0
  278. if (paymentRecord.subscription.pendingChange.nextAddOns) {
  279. const pendingAddOns =
  280. paymentRecord.subscription.pendingChange.nextAddOns
  281. pendingAddOns.forEach(addOn => {
  282. pendingAddOnPrice += addOn.quantity * addOn.unitPrice
  283. if (addOn.code === pendingPlan.membersLimitAddOn) {
  284. pendingAdditionalLicenses += addOn.quantity
  285. }
  286. })
  287. // Need to calculate tax ourselves as we don't get tax amounts for pending subs
  288. pendingAddOnTax =
  289. personalSubscription.payment.taxRate * pendingAddOnPrice
  290. pendingPlan.addOns = pendingAddOns
  291. }
  292. const pendingSubscriptionTax =
  293. personalSubscription.payment.taxRate *
  294. paymentRecord.subscription.pendingChange.nextPlanPrice
  295. const totalPrice =
  296. paymentRecord.subscription.pendingChange.nextPlanPrice +
  297. pendingAddOnPrice +
  298. pendingAddOnTax +
  299. pendingSubscriptionTax
  300. personalSubscription.payment.displayPrice = formatCurrency(
  301. totalPrice,
  302. paymentRecord.subscription.currency,
  303. locale
  304. )
  305. personalSubscription.payment.planOnlyDisplayPrice =
  306. getPlanOnlyDisplayPrice(
  307. totalPrice,
  308. taxRate,
  309. paymentRecord.subscription.pendingChange.nextAddOns
  310. )
  311. personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense =
  312. getAddOnDisplayPricesWithoutAdditionalLicense(
  313. taxRate,
  314. paymentRecord.subscription.pendingChange.nextAddOns
  315. )
  316. const pendingTotalLicenses =
  317. (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
  318. personalSubscription.payment.pendingAdditionalLicenses =
  319. pendingAdditionalLicenses
  320. personalSubscription.payment.pendingTotalLicenses = pendingTotalLicenses
  321. personalSubscription.pendingPlan = pendingPlan
  322. } else {
  323. const totalPrice = paymentRecord.subscription.planPrice + addOnPrice + tax
  324. personalSubscription.payment.displayPrice = formatCurrency(
  325. totalPrice,
  326. paymentRecord.subscription.currency,
  327. locale
  328. )
  329. personalSubscription.payment.planOnlyDisplayPrice =
  330. getPlanOnlyDisplayPrice(totalPrice, taxRate, addOns)
  331. personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense =
  332. getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns)
  333. }
  334. }
  335. return {
  336. personalSubscription,
  337. managedGroupSubscriptions,
  338. memberGroupSubscriptions,
  339. currentInstitutionsWithLicence,
  340. managedInstitutions,
  341. managedPublishers,
  342. }
  343. }
  344. /**
  345. * @param {{_id: string}} user
  346. * @returns {Promise<{bestSubscription:Subscription,individualSubscription:DBSubscription|null,memberGroupSubscriptions:DBSubscription[]}>}
  347. */
  348. async function getUsersSubscriptionDetails(user) {
  349. let [
  350. individualSubscription,
  351. memberGroupSubscriptions,
  352. currentInstitutionsWithLicence,
  353. ] = await Promise.all([
  354. SubscriptionLocator.promises.getUsersSubscription(user),
  355. SubscriptionLocator.promises.getMemberSubscriptions(user),
  356. InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(user._id),
  357. ])
  358. if (
  359. individualSubscription &&
  360. !individualSubscription.customAccount &&
  361. SubscriptionHelper.getPaymentProviderSubscriptionId(
  362. individualSubscription
  363. ) &&
  364. !SubscriptionHelper.getPaidSubscriptionState(individualSubscription)
  365. ) {
  366. const paymentResults = await Modules.promises.hooks.fire(
  367. 'getPaymentFromRecordPromise',
  368. individualSubscription
  369. )
  370. await Modules.promises.hooks.fire(
  371. 'syncSubscription',
  372. paymentResults[0]?.subscription,
  373. individualSubscription
  374. )
  375. individualSubscription =
  376. await SubscriptionLocator.promises.getUsersSubscription(user)
  377. }
  378. let bestSubscription = {
  379. type: 'free',
  380. }
  381. if (currentInstitutionsWithLicence?.length) {
  382. for (const institutionMembership of currentInstitutionsWithLicence) {
  383. const plan = PlansLocator.findLocalPlanInSettings(
  384. Settings.institutionPlanCode
  385. )
  386. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  387. bestSubscription = {
  388. type: 'commons',
  389. subscription: institutionMembership,
  390. plan,
  391. }
  392. }
  393. }
  394. }
  395. if (memberGroupSubscriptions?.length) {
  396. for (const groupSubscription of memberGroupSubscriptions) {
  397. const plan = PlansLocator.findLocalPlanInSettings(
  398. groupSubscription.planCode
  399. )
  400. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  401. const groupDataForView = {}
  402. if (groupSubscription.teamName) {
  403. groupDataForView.teamName = groupSubscription.teamName
  404. }
  405. const remainingTrialDays = _getRemainingTrialDays(groupSubscription)
  406. bestSubscription = {
  407. type: 'group',
  408. subscription: groupDataForView,
  409. plan,
  410. remainingTrialDays,
  411. }
  412. }
  413. }
  414. }
  415. if (individualSubscription && !individualSubscription.groupPlan) {
  416. if (
  417. isStandaloneAiAddOnPlanCode(individualSubscription.planCode) &&
  418. bestSubscription.type === 'free'
  419. ) {
  420. bestSubscription = { type: 'standalone-ai-add-on' }
  421. } else {
  422. const plan = PlansLocator.findLocalPlanInSettings(
  423. individualSubscription.planCode
  424. )
  425. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  426. const remainingTrialDays = _getRemainingTrialDays(
  427. individualSubscription
  428. )
  429. bestSubscription = {
  430. type: 'individual',
  431. subscription: individualSubscription,
  432. plan,
  433. remainingTrialDays,
  434. }
  435. }
  436. }
  437. }
  438. return { bestSubscription, individualSubscription, memberGroupSubscriptions }
  439. }
  440. function buildPlansList(currentPlan, isInTrial) {
  441. const { plans } = Settings
  442. const allPlans = {}
  443. plans.forEach(plan => {
  444. allPlans[plan.planCode] = plan
  445. })
  446. const result = { allPlans }
  447. if (currentPlan) {
  448. result.planCodesChangingAtTermEnd = _.map(
  449. _.filter(plans, plan => {
  450. if (!plan.hideFromUsers) {
  451. return SubscriptionHelper.shouldPlanChangeAtTermEnd(
  452. currentPlan,
  453. plan,
  454. isInTrial
  455. )
  456. }
  457. }),
  458. 'planCode'
  459. )
  460. }
  461. result.studentAccounts = _.filter(
  462. plans,
  463. plan => plan.planCode.indexOf('student') !== -1
  464. )
  465. result.groupMonthlyPlans = _.filter(
  466. plans,
  467. plan => plan.groupPlan && !plan.annual
  468. )
  469. result.groupAnnualPlans = _.filter(
  470. plans,
  471. plan => plan.groupPlan && plan.annual
  472. )
  473. result.individualMonthlyPlans = _.filter(
  474. plans,
  475. plan =>
  476. !plan.groupPlan &&
  477. !plan.annual &&
  478. plan.planCode !== 'personal' && // Prevent the personal plan from appearing on the change-plans page
  479. plan.planCode.indexOf('student') === -1
  480. )
  481. result.individualAnnualPlans = _.filter(
  482. plans,
  483. plan =>
  484. !plan.groupPlan && plan.annual && plan.planCode.indexOf('student') === -1
  485. )
  486. return result
  487. }
  488. function _isPlanEqualOrBetter(planA, planB) {
  489. return FeaturesHelper.isFeatureSetBetter(
  490. planA?.features || {},
  491. planB?.features || {}
  492. )
  493. }
  494. function _getRemainingTrialDays(subscription) {
  495. const now = new Date()
  496. const trialEndDate =
  497. SubscriptionHelper.getSubscriptionTrialEndsAt(subscription)
  498. return trialEndDate && trialEndDate > now
  499. ? Math.ceil(
  500. (trialEndDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
  501. )
  502. : -1
  503. }
  504. function buildGroupSubscriptionForView(groupSubscription) {
  505. // most group plans in Recurly should be in form "group_plancode_size_usage"
  506. const planLevelFromGroupPlanCode = groupSubscription.planCode.substr(6, 12)
  507. if (planLevelFromGroupPlanCode === 'professional') {
  508. groupSubscription.planLevelName = 'Professional'
  509. } else if (planLevelFromGroupPlanCode === 'collaborator') {
  510. groupSubscription.planLevelName = 'Standard'
  511. }
  512. // there are some group subscription entries that have the personal plancodes...
  513. // this fallback tries to still show the right thing in these cases:
  514. if (!groupSubscription.planLevelName) {
  515. if (groupSubscription.planCode.startsWith('professional')) {
  516. groupSubscription.planLevelName = 'Professional'
  517. } else if (groupSubscription.planCode.startsWith('collaborator')) {
  518. groupSubscription.planLevelName = 'Standard'
  519. } else {
  520. // if we still don't have anything, we can show the plan name (eg, v1 Pro):
  521. const plan = PlansLocator.findLocalPlanInSettings(
  522. groupSubscription.planCode
  523. )
  524. groupSubscription.planLevelName = plan
  525. ? plan.name
  526. : groupSubscription.planCode
  527. }
  528. }
  529. }
  530. function buildPlansListForSubscriptionDash(currentPlan, isInTrial) {
  531. const allPlansData = buildPlansList(currentPlan, isInTrial)
  532. const plans = []
  533. // only list individual and visible plans for "change plans" UI
  534. if (allPlansData.studentAccounts) {
  535. plans.push(
  536. ...allPlansData.studentAccounts.filter(plan => !plan.hideFromUsers)
  537. )
  538. }
  539. if (allPlansData.individualMonthlyPlans) {
  540. plans.push(
  541. ...allPlansData.individualMonthlyPlans.filter(plan => !plan.hideFromUsers)
  542. )
  543. }
  544. if (allPlansData.individualAnnualPlans) {
  545. plans.push(
  546. ...allPlansData.individualAnnualPlans.filter(plan => !plan.hideFromUsers)
  547. )
  548. }
  549. return {
  550. plans,
  551. planCodesChangingAtTermEnd: allPlansData.planCodesChangingAtTermEnd,
  552. }
  553. }
  554. module.exports = {
  555. buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel),
  556. buildPlansList,
  557. buildPlansListForSubscriptionDash,
  558. promises: {
  559. buildUsersSubscriptionViewModel,
  560. getUsersSubscriptionDetails,
  561. },
  562. }