SubscriptionViewModelBuilder.js 17 KB

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