SubscriptionViewModelBuilder.js 19 KB

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