SubscriptionViewModelBuilder.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  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.customAccount &&
  342. individualSubscription.recurlySubscription_id &&
  343. !individualSubscription.recurly?.state
  344. ) {
  345. const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
  346. individualSubscription.recurlySubscription_id,
  347. { includeAccount: true }
  348. )
  349. await SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
  350. recurlySubscription,
  351. individualSubscription
  352. )
  353. individualSubscription =
  354. await SubscriptionLocator.promises.getUsersSubscription(user)
  355. }
  356. let bestSubscription = {
  357. type: 'free',
  358. }
  359. if (currentInstitutionsWithLicence?.length) {
  360. for (const institutionMembership of currentInstitutionsWithLicence) {
  361. const plan = PlansLocator.findLocalPlanInSettings(
  362. Settings.institutionPlanCode
  363. )
  364. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  365. bestSubscription = {
  366. type: 'commons',
  367. subscription: institutionMembership,
  368. plan,
  369. }
  370. }
  371. }
  372. }
  373. if (memberGroupSubscriptions?.length) {
  374. for (const groupSubscription of memberGroupSubscriptions) {
  375. const plan = PlansLocator.findLocalPlanInSettings(
  376. groupSubscription.planCode
  377. )
  378. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  379. const remainingTrialDays = _getRemainingTrialDays(groupSubscription)
  380. bestSubscription = {
  381. type: 'group',
  382. subscription: groupSubscription,
  383. plan,
  384. remainingTrialDays,
  385. }
  386. }
  387. }
  388. }
  389. if (individualSubscription && !individualSubscription.groupPlan) {
  390. const plan = PlansLocator.findLocalPlanInSettings(
  391. individualSubscription.planCode
  392. )
  393. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  394. const remainingTrialDays = _getRemainingTrialDays(individualSubscription)
  395. bestSubscription = {
  396. type: 'individual',
  397. subscription: individualSubscription,
  398. plan,
  399. remainingTrialDays,
  400. }
  401. }
  402. }
  403. return bestSubscription
  404. }
  405. function buildPlansList(currentPlan) {
  406. const { plans } = Settings
  407. const allPlans = {}
  408. plans.forEach(plan => {
  409. allPlans[plan.planCode] = plan
  410. })
  411. const result = { allPlans }
  412. if (currentPlan) {
  413. result.planCodesChangingAtTermEnd = _.pluck(
  414. _.filter(plans, plan => {
  415. if (!plan.hideFromUsers) {
  416. return SubscriptionHelper.shouldPlanChangeAtTermEnd(currentPlan, plan)
  417. }
  418. }),
  419. 'planCode'
  420. )
  421. }
  422. result.studentAccounts = _.filter(
  423. plans,
  424. plan => plan.planCode.indexOf('student') !== -1
  425. )
  426. result.groupMonthlyPlans = _.filter(
  427. plans,
  428. plan => plan.groupPlan && !plan.annual
  429. )
  430. result.groupAnnualPlans = _.filter(
  431. plans,
  432. plan => plan.groupPlan && plan.annual
  433. )
  434. result.individualMonthlyPlans = _.filter(
  435. plans,
  436. plan =>
  437. !plan.groupPlan &&
  438. !plan.annual &&
  439. plan.planCode !== 'personal' && // Prevent the personal plan from appearing on the change-plans page
  440. plan.planCode.indexOf('student') === -1
  441. )
  442. result.individualAnnualPlans = _.filter(
  443. plans,
  444. plan =>
  445. !plan.groupPlan && plan.annual && plan.planCode.indexOf('student') === -1
  446. )
  447. return result
  448. }
  449. function _isPlanEqualOrBetter(planA, planB) {
  450. return FeaturesHelper.isFeatureSetBetter(
  451. planA?.features || {},
  452. planB?.features || {}
  453. )
  454. }
  455. function _getRemainingTrialDays(subscription) {
  456. const now = new Date()
  457. const trialEndDate = subscription.recurly?.trialEndsAt
  458. return trialEndDate && trialEndDate > now
  459. ? Math.ceil(
  460. (trialEndDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
  461. )
  462. : -1
  463. }
  464. function buildGroupSubscriptionForView(groupSubscription) {
  465. // most group plans in Recurly should be in form "group_plancode_size_usage"
  466. const planLevelFromGroupPlanCode = groupSubscription.planCode.substr(6, 12)
  467. if (planLevelFromGroupPlanCode === 'professional') {
  468. groupSubscription.planLevelName = 'Professional'
  469. } else if (planLevelFromGroupPlanCode === 'collaborator') {
  470. groupSubscription.planLevelName = 'Standard'
  471. }
  472. // there are some group subscription entries that have the personal plancodes...
  473. // this fallback tries to still show the right thing in these cases:
  474. if (!groupSubscription.planLevelName) {
  475. if (groupSubscription.planCode.startsWith('professional')) {
  476. groupSubscription.planLevelName = 'Professional'
  477. } else if (groupSubscription.planCode.startsWith('collaborator')) {
  478. groupSubscription.planLevelName = 'Standard'
  479. } else {
  480. // if we still don't have anything, we can show the plan name (eg, v1 Pro):
  481. const plan = PlansLocator.findLocalPlanInSettings(
  482. groupSubscription.planCode
  483. )
  484. groupSubscription.planLevelName = plan
  485. ? plan.name
  486. : groupSubscription.planCode
  487. }
  488. }
  489. }
  490. module.exports = {
  491. buildUsersSubscriptionViewModel,
  492. buildPlansList,
  493. getBestSubscription: callbackify(getBestSubscription),
  494. promises: {
  495. buildUsersSubscriptionViewModel: promisify(buildUsersSubscriptionViewModel),
  496. getRedirectToHostedPage,
  497. getBestSubscription,
  498. },
  499. }