SubscriptionViewModelBuilder.js 18 KB

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