SubscriptionViewModelBuilder.js 17 KB

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