SubscriptionViewModelBuilder.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  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. personalSubscription.payment = {
  221. taxRate,
  222. billingDetailsLink:
  223. paymentRecord.subscription.service === 'recurly'
  224. ? buildHostedLink('billing-details')
  225. : null,
  226. accountManagementLink: buildHostedLink('account-management'),
  227. additionalLicenses,
  228. addOns,
  229. totalLicenses,
  230. nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
  231. paymentRecord.subscription.periodEnd
  232. ),
  233. nextPaymentDueDate: SubscriptionFormatters.formatDate(
  234. paymentRecord.subscription.periodEnd
  235. ),
  236. currency: paymentRecord.subscription.currency,
  237. state: paymentRecord.subscription.state,
  238. trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
  239. paymentRecord.subscription.trialPeriodEnd
  240. ),
  241. trialEndsAt: paymentRecord.subscription.trialPeriodEnd,
  242. activeCoupons: paymentRecord.coupons,
  243. accountEmail: paymentRecord.account.email,
  244. hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice,
  245. pausedAt: paymentRecord.subscription.pausePeriodStart,
  246. remainingPauseCycles: paymentRecord.subscription.remainingPauseCycles,
  247. }
  248. if (paymentRecord.subscription.pendingChange) {
  249. const pendingPlanCode =
  250. paymentRecord.subscription.pendingChange.nextPlanCode
  251. const pendingPlan = PlansLocator.findLocalPlanInSettings(pendingPlanCode)
  252. if (pendingPlan == null) {
  253. throw new Error(`No plan found for planCode '${pendingPlanCode}'`)
  254. }
  255. let pendingAdditionalLicenses = 0
  256. let pendingAddOnTax = 0
  257. let pendingAddOnPrice = 0
  258. if (paymentRecord.subscription.pendingChange.nextAddOns) {
  259. const pendingAddOns =
  260. paymentRecord.subscription.pendingChange.nextAddOns
  261. pendingAddOns.forEach(addOn => {
  262. pendingAddOnPrice += addOn.quantity * addOn.unitPrice
  263. if (addOn.code === pendingPlan.membersLimitAddOn) {
  264. pendingAdditionalLicenses += addOn.quantity
  265. }
  266. })
  267. // Need to calculate tax ourselves as we don't get tax amounts for pending subs
  268. pendingAddOnTax =
  269. personalSubscription.payment.taxRate * pendingAddOnPrice
  270. pendingPlan.addOns = pendingAddOns
  271. }
  272. const pendingSubscriptionTax =
  273. personalSubscription.payment.taxRate *
  274. paymentRecord.subscription.pendingChange.nextPlanPrice
  275. const totalPrice =
  276. paymentRecord.subscription.pendingChange.nextPlanPrice +
  277. pendingAddOnPrice +
  278. pendingAddOnTax +
  279. pendingSubscriptionTax
  280. personalSubscription.payment.displayPrice = formatCurrency(
  281. totalPrice,
  282. paymentRecord.subscription.currency,
  283. locale
  284. )
  285. personalSubscription.payment.planOnlyDisplayPrice =
  286. getPlanOnlyDisplayPrice(
  287. totalPrice,
  288. taxRate,
  289. paymentRecord.subscription.pendingChange.nextAddOns
  290. )
  291. personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense =
  292. getAddOnDisplayPricesWithoutAdditionalLicense(
  293. taxRate,
  294. paymentRecord.subscription.pendingChange.nextAddOns
  295. )
  296. const pendingTotalLicenses =
  297. (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
  298. personalSubscription.payment.pendingAdditionalLicenses =
  299. pendingAdditionalLicenses
  300. personalSubscription.payment.pendingTotalLicenses = pendingTotalLicenses
  301. personalSubscription.pendingPlan = pendingPlan
  302. } else {
  303. const totalPrice = paymentRecord.subscription.planPrice + addOnPrice + tax
  304. personalSubscription.payment.displayPrice = formatCurrency(
  305. totalPrice,
  306. paymentRecord.subscription.currency,
  307. locale
  308. )
  309. personalSubscription.payment.planOnlyDisplayPrice =
  310. getPlanOnlyDisplayPrice(totalPrice, taxRate, addOns)
  311. personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense =
  312. getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns)
  313. }
  314. }
  315. return {
  316. personalSubscription,
  317. managedGroupSubscriptions,
  318. memberGroupSubscriptions,
  319. currentInstitutionsWithLicence,
  320. managedInstitutions,
  321. managedPublishers,
  322. }
  323. }
  324. /**
  325. * @param {{_id: string}} user
  326. * @returns {Promise<Subscription>}
  327. */
  328. async function getBestSubscription(user) {
  329. let [
  330. individualSubscription,
  331. memberGroupSubscriptions,
  332. currentInstitutionsWithLicence,
  333. ] = await Promise.all([
  334. SubscriptionLocator.promises.getUsersSubscription(user),
  335. SubscriptionLocator.promises.getMemberSubscriptions(user),
  336. InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(user._id),
  337. ])
  338. if (
  339. individualSubscription &&
  340. !individualSubscription.customAccount &&
  341. individualSubscription.recurlySubscription_id &&
  342. !individualSubscription.recurlyStatus?.state
  343. ) {
  344. const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
  345. individualSubscription.recurlySubscription_id,
  346. { includeAccount: true }
  347. )
  348. await SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
  349. recurlySubscription,
  350. individualSubscription
  351. )
  352. individualSubscription =
  353. await SubscriptionLocator.promises.getUsersSubscription(user)
  354. }
  355. let bestSubscription = {
  356. type: 'free',
  357. }
  358. if (currentInstitutionsWithLicence?.length) {
  359. for (const institutionMembership of currentInstitutionsWithLicence) {
  360. const plan = PlansLocator.findLocalPlanInSettings(
  361. Settings.institutionPlanCode
  362. )
  363. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  364. bestSubscription = {
  365. type: 'commons',
  366. subscription: institutionMembership,
  367. plan,
  368. }
  369. }
  370. }
  371. }
  372. if (memberGroupSubscriptions?.length) {
  373. for (const groupSubscription of memberGroupSubscriptions) {
  374. const plan = PlansLocator.findLocalPlanInSettings(
  375. groupSubscription.planCode
  376. )
  377. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  378. const groupDataForView = {}
  379. if (groupSubscription.teamName) {
  380. groupDataForView.teamName = groupSubscription.teamName
  381. }
  382. const remainingTrialDays = _getRemainingTrialDays(groupSubscription)
  383. bestSubscription = {
  384. type: 'group',
  385. subscription: groupDataForView,
  386. plan,
  387. remainingTrialDays,
  388. }
  389. }
  390. }
  391. }
  392. if (individualSubscription && !individualSubscription.groupPlan) {
  393. if (
  394. isStandaloneAiAddOnPlanCode(individualSubscription.planCode) &&
  395. bestSubscription.type === 'free'
  396. ) {
  397. bestSubscription = { type: 'standalone-ai-add-on' }
  398. } else {
  399. const plan = PlansLocator.findLocalPlanInSettings(
  400. individualSubscription.planCode
  401. )
  402. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  403. const remainingTrialDays = _getRemainingTrialDays(
  404. individualSubscription
  405. )
  406. bestSubscription = {
  407. type: 'individual',
  408. subscription: individualSubscription,
  409. plan,
  410. remainingTrialDays,
  411. }
  412. }
  413. }
  414. }
  415. return bestSubscription
  416. }
  417. function buildPlansList(currentPlan) {
  418. const { plans } = Settings
  419. const allPlans = {}
  420. plans.forEach(plan => {
  421. allPlans[plan.planCode] = plan
  422. })
  423. const result = { allPlans }
  424. if (currentPlan) {
  425. result.planCodesChangingAtTermEnd = _.map(
  426. _.filter(plans, plan => {
  427. if (!plan.hideFromUsers) {
  428. return SubscriptionHelper.shouldPlanChangeAtTermEnd(currentPlan, plan)
  429. }
  430. }),
  431. 'planCode'
  432. )
  433. }
  434. result.studentAccounts = _.filter(
  435. plans,
  436. plan => plan.planCode.indexOf('student') !== -1
  437. )
  438. result.groupMonthlyPlans = _.filter(
  439. plans,
  440. plan => plan.groupPlan && !plan.annual
  441. )
  442. result.groupAnnualPlans = _.filter(
  443. plans,
  444. plan => plan.groupPlan && plan.annual
  445. )
  446. result.individualMonthlyPlans = _.filter(
  447. plans,
  448. plan =>
  449. !plan.groupPlan &&
  450. !plan.annual &&
  451. plan.planCode !== 'personal' && // Prevent the personal plan from appearing on the change-plans page
  452. plan.planCode.indexOf('student') === -1
  453. )
  454. result.individualAnnualPlans = _.filter(
  455. plans,
  456. plan =>
  457. !plan.groupPlan && plan.annual && plan.planCode.indexOf('student') === -1
  458. )
  459. return result
  460. }
  461. function _isPlanEqualOrBetter(planA, planB) {
  462. return FeaturesHelper.isFeatureSetBetter(
  463. planA?.features || {},
  464. planB?.features || {}
  465. )
  466. }
  467. function _getRemainingTrialDays(subscription) {
  468. const now = new Date()
  469. const trialEndDate = subscription.recurlyStatus?.trialEndsAt
  470. return trialEndDate && trialEndDate > now
  471. ? Math.ceil(
  472. (trialEndDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
  473. )
  474. : -1
  475. }
  476. function buildGroupSubscriptionForView(groupSubscription) {
  477. // most group plans in Recurly should be in form "group_plancode_size_usage"
  478. const planLevelFromGroupPlanCode = groupSubscription.planCode.substr(6, 12)
  479. if (planLevelFromGroupPlanCode === 'professional') {
  480. groupSubscription.planLevelName = 'Professional'
  481. } else if (planLevelFromGroupPlanCode === 'collaborator') {
  482. groupSubscription.planLevelName = 'Standard'
  483. }
  484. // there are some group subscription entries that have the personal plancodes...
  485. // this fallback tries to still show the right thing in these cases:
  486. if (!groupSubscription.planLevelName) {
  487. if (groupSubscription.planCode.startsWith('professional')) {
  488. groupSubscription.planLevelName = 'Professional'
  489. } else if (groupSubscription.planCode.startsWith('collaborator')) {
  490. groupSubscription.planLevelName = 'Standard'
  491. } else {
  492. // if we still don't have anything, we can show the plan name (eg, v1 Pro):
  493. const plan = PlansLocator.findLocalPlanInSettings(
  494. groupSubscription.planCode
  495. )
  496. groupSubscription.planLevelName = plan
  497. ? plan.name
  498. : groupSubscription.planCode
  499. }
  500. }
  501. }
  502. function buildPlansListForSubscriptionDash(currentPlan) {
  503. const allPlansData = buildPlansList(currentPlan)
  504. const plans = []
  505. // only list individual and visible plans for "change plans" UI
  506. if (allPlansData.studentAccounts) {
  507. plans.push(
  508. ...allPlansData.studentAccounts.filter(plan => !plan.hideFromUsers)
  509. )
  510. }
  511. if (allPlansData.individualMonthlyPlans) {
  512. plans.push(
  513. ...allPlansData.individualMonthlyPlans.filter(plan => !plan.hideFromUsers)
  514. )
  515. }
  516. if (allPlansData.individualAnnualPlans) {
  517. plans.push(
  518. ...allPlansData.individualAnnualPlans.filter(plan => !plan.hideFromUsers)
  519. )
  520. }
  521. return {
  522. plans,
  523. planCodesChangingAtTermEnd: allPlansData.planCodesChangingAtTermEnd,
  524. }
  525. }
  526. module.exports = {
  527. buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel),
  528. buildPlansList,
  529. buildPlansListForSubscriptionDash,
  530. getBestSubscription: callbackify(getBestSubscription),
  531. promises: {
  532. buildUsersSubscriptionViewModel,
  533. getBestSubscription,
  534. },
  535. }