SubscriptionViewModelBuilder.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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 V1SubscriptionManager = require('./V1SubscriptionManager')
  7. const InstitutionsGetter = require('../Institutions/InstitutionsGetter')
  8. const PublishersGetter = require('../Publishers/PublishersGetter')
  9. const sanitizeHtml = require('sanitize-html')
  10. const _ = require('underscore')
  11. const async = require('async')
  12. const SubscriptionHelper = require('./SubscriptionHelper')
  13. const { promisify } = require('../../util/promises')
  14. const { InvalidError, NotFoundError } = require('../Errors/Errors')
  15. function buildHostedLink(type) {
  16. return `/user/subscription/recurly/${type}`
  17. }
  18. async function getRedirectToHostedPage(userId, pageType) {
  19. if (!['billing-details', 'account-management'].includes(pageType)) {
  20. throw new InvalidError('unexpected page type')
  21. }
  22. const personalSubscription =
  23. await SubscriptionLocator.promises.getUsersSubscription(userId)
  24. const recurlySubscriptionId = personalSubscription?.recurlySubscription_id
  25. if (!recurlySubscriptionId) {
  26. throw new NotFoundError('not a recurly subscription')
  27. }
  28. const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
  29. recurlySubscriptionId,
  30. { includeAccount: true }
  31. )
  32. const recurlySubdomain = Settings.apis.recurly.subdomain
  33. const hostedLoginToken = recurlySubscription.account.hosted_login_token
  34. if (!hostedLoginToken) {
  35. throw new Error('recurly account does not have hosted login token')
  36. }
  37. let path = ''
  38. if (pageType === 'billing-details') {
  39. path = 'billing_info/edit?ht='
  40. }
  41. return [
  42. 'https://',
  43. recurlySubdomain,
  44. '.recurly.com/account/',
  45. path,
  46. hostedLoginToken,
  47. ].join('')
  48. }
  49. function buildUsersSubscriptionViewModel(user, callback) {
  50. async.auto(
  51. {
  52. personalSubscription(cb) {
  53. SubscriptionLocator.getUsersSubscription(user, cb)
  54. },
  55. recurlySubscription: [
  56. 'personalSubscription',
  57. (cb, { personalSubscription }) => {
  58. if (
  59. personalSubscription == null ||
  60. personalSubscription.recurlySubscription_id == null ||
  61. personalSubscription.recurlySubscription_id === ''
  62. ) {
  63. return cb(null, null)
  64. }
  65. RecurlyWrapper.getSubscription(
  66. personalSubscription.recurlySubscription_id,
  67. { includeAccount: true },
  68. cb
  69. )
  70. },
  71. ],
  72. recurlyCoupons: [
  73. 'recurlySubscription',
  74. (cb, { recurlySubscription }) => {
  75. if (!recurlySubscription) {
  76. return cb(null, null)
  77. }
  78. const accountId = recurlySubscription.account.account_code
  79. RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
  80. },
  81. ],
  82. plan: [
  83. 'personalSubscription',
  84. (cb, { personalSubscription }) => {
  85. if (personalSubscription == null) {
  86. return cb()
  87. }
  88. const plan = PlansLocator.findLocalPlanInSettings(
  89. personalSubscription.planCode
  90. )
  91. if (plan == null) {
  92. return cb(
  93. new Error(
  94. `No plan found for planCode '${personalSubscription.planCode}'`
  95. )
  96. )
  97. }
  98. cb(null, plan)
  99. },
  100. ],
  101. memberGroupSubscriptions(cb) {
  102. SubscriptionLocator.getMemberSubscriptions(user, cb)
  103. },
  104. managedGroupSubscriptions(cb) {
  105. SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
  106. },
  107. currentInstitutionsWithLicence(cb) {
  108. InstitutionsGetter.getCurrentInstitutionsWithLicence(user._id, cb)
  109. },
  110. managedInstitutions(cb) {
  111. InstitutionsGetter.getManagedInstitutions(user._id, cb)
  112. },
  113. managedPublishers(cb) {
  114. PublishersGetter.getManagedPublishers(user._id, cb)
  115. },
  116. v1SubscriptionStatus(cb) {
  117. V1SubscriptionManager.getSubscriptionStatusFromV1(
  118. user._id,
  119. (error, status, v1Id) => {
  120. if (error) {
  121. return cb(error)
  122. }
  123. cb(null, status)
  124. }
  125. )
  126. },
  127. },
  128. (err, results) => {
  129. if (err) {
  130. return callback(err)
  131. }
  132. let {
  133. personalSubscription,
  134. memberGroupSubscriptions,
  135. managedGroupSubscriptions,
  136. currentInstitutionsWithLicence,
  137. managedInstitutions,
  138. managedPublishers,
  139. v1SubscriptionStatus,
  140. recurlySubscription,
  141. recurlyCoupons,
  142. plan,
  143. } = results
  144. if (memberGroupSubscriptions == null) {
  145. memberGroupSubscriptions = []
  146. }
  147. if (managedGroupSubscriptions == null) {
  148. managedGroupSubscriptions = []
  149. }
  150. if (managedInstitutions == null) {
  151. managedInstitutions = []
  152. }
  153. if (v1SubscriptionStatus == null) {
  154. v1SubscriptionStatus = {}
  155. }
  156. if (recurlyCoupons == null) {
  157. recurlyCoupons = []
  158. }
  159. if (
  160. personalSubscription &&
  161. typeof personalSubscription.toObject === 'function'
  162. ) {
  163. // Downgrade from Mongoose object, so we can add a recurly and plan attribute
  164. personalSubscription = personalSubscription.toObject()
  165. }
  166. if (plan != null) {
  167. personalSubscription.plan = plan
  168. }
  169. if (personalSubscription && recurlySubscription) {
  170. const tax = recurlySubscription.tax_in_cents || 0
  171. // Some plans allow adding more seats than the base plan provides.
  172. // This is recorded as a subscription add on.
  173. // Note: tax_in_cents already includes the tax for any addon.
  174. let addOnPrice = 0
  175. let additionalLicenses = 0
  176. if (
  177. plan.membersLimitAddOn &&
  178. Array.isArray(recurlySubscription.subscription_add_ons)
  179. ) {
  180. recurlySubscription.subscription_add_ons.forEach(addOn => {
  181. if (addOn.add_on_code === plan.membersLimitAddOn) {
  182. addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
  183. additionalLicenses += addOn.quantity
  184. }
  185. })
  186. }
  187. const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
  188. personalSubscription.recurly = {
  189. tax,
  190. taxRate: recurlySubscription.tax_rate
  191. ? parseFloat(recurlySubscription.tax_rate._)
  192. : 0,
  193. billingDetailsLink: buildHostedLink('billing-details'),
  194. accountManagementLink: buildHostedLink('account-management'),
  195. additionalLicenses,
  196. totalLicenses,
  197. nextPaymentDueAt: SubscriptionFormatters.formatDate(
  198. recurlySubscription.current_period_ends_at
  199. ),
  200. currency: recurlySubscription.currency,
  201. state: recurlySubscription.state,
  202. trialEndsAtFormatted: SubscriptionFormatters.formatDate(
  203. recurlySubscription.trial_ends_at
  204. ),
  205. trial_ends_at: recurlySubscription.trial_ends_at,
  206. activeCoupons: recurlyCoupons,
  207. account: recurlySubscription.account,
  208. }
  209. if (recurlySubscription.pending_subscription) {
  210. const pendingPlan = PlansLocator.findLocalPlanInSettings(
  211. recurlySubscription.pending_subscription.plan.plan_code
  212. )
  213. if (pendingPlan == null) {
  214. return callback(
  215. new Error(
  216. `No plan found for planCode '${personalSubscription.planCode}'`
  217. )
  218. )
  219. }
  220. let pendingAdditionalLicenses = 0
  221. let pendingAddOnTax = 0
  222. let pendingAddOnPrice = 0
  223. if (recurlySubscription.pending_subscription.subscription_add_ons) {
  224. if (
  225. pendingPlan.membersLimitAddOn &&
  226. Array.isArray(
  227. recurlySubscription.pending_subscription.subscription_add_ons
  228. )
  229. ) {
  230. recurlySubscription.pending_subscription.subscription_add_ons.forEach(
  231. addOn => {
  232. if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
  233. pendingAddOnPrice +=
  234. addOn.quantity * addOn.unit_amount_in_cents
  235. pendingAdditionalLicenses += addOn.quantity
  236. }
  237. }
  238. )
  239. }
  240. // Need to calculate tax ourselves as we don't get tax amounts for pending subs
  241. pendingAddOnTax =
  242. personalSubscription.recurly.taxRate * pendingAddOnPrice
  243. }
  244. const pendingSubscriptionTax =
  245. personalSubscription.recurly.taxRate *
  246. recurlySubscription.pending_subscription.unit_amount_in_cents
  247. personalSubscription.recurly.displayPrice =
  248. SubscriptionFormatters.formatPrice(
  249. recurlySubscription.pending_subscription.unit_amount_in_cents +
  250. pendingAddOnPrice +
  251. pendingAddOnTax +
  252. pendingSubscriptionTax,
  253. recurlySubscription.currency
  254. )
  255. const pendingTotalLicenses =
  256. (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
  257. personalSubscription.recurly.pendingAdditionalLicenses =
  258. pendingAdditionalLicenses
  259. personalSubscription.recurly.pendingTotalLicenses =
  260. pendingTotalLicenses
  261. personalSubscription.pendingPlan = pendingPlan
  262. } else {
  263. personalSubscription.recurly.displayPrice =
  264. SubscriptionFormatters.formatPrice(
  265. recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
  266. recurlySubscription.currency
  267. )
  268. }
  269. }
  270. for (const memberGroupSubscription of memberGroupSubscriptions) {
  271. if (memberGroupSubscription.teamNotice) {
  272. memberGroupSubscription.teamNotice = sanitizeHtml(
  273. memberGroupSubscription.teamNotice
  274. )
  275. }
  276. }
  277. callback(null, {
  278. personalSubscription,
  279. managedGroupSubscriptions,
  280. memberGroupSubscriptions,
  281. currentInstitutionsWithLicence,
  282. managedInstitutions,
  283. managedPublishers,
  284. v1SubscriptionStatus,
  285. })
  286. }
  287. )
  288. }
  289. function buildPlansList(currentPlan) {
  290. const { plans } = Settings
  291. const allPlans = {}
  292. plans.forEach(plan => {
  293. allPlans[plan.planCode] = plan
  294. })
  295. const result = { allPlans }
  296. if (currentPlan) {
  297. result.planCodesChangingAtTermEnd = _.pluck(
  298. _.filter(plans, plan => {
  299. if (!plan.hideFromUsers) {
  300. return SubscriptionHelper.shouldPlanChangeAtTermEnd(currentPlan, plan)
  301. }
  302. }),
  303. 'planCode'
  304. )
  305. }
  306. result.studentAccounts = _.filter(
  307. plans,
  308. plan => plan.planCode.indexOf('student') !== -1
  309. )
  310. result.groupMonthlyPlans = _.filter(
  311. plans,
  312. plan => plan.groupPlan && !plan.annual
  313. )
  314. result.groupAnnualPlans = _.filter(
  315. plans,
  316. plan => plan.groupPlan && plan.annual
  317. )
  318. result.individualMonthlyPlans = _.filter(
  319. plans,
  320. plan =>
  321. !plan.groupPlan &&
  322. !plan.annual &&
  323. plan.planCode !== 'personal' && // Prevent the personal plan from appearing on the change-plans page
  324. plan.planCode.indexOf('student') === -1
  325. )
  326. result.individualAnnualPlans = _.filter(
  327. plans,
  328. plan =>
  329. !plan.groupPlan && plan.annual && plan.planCode.indexOf('student') === -1
  330. )
  331. return result
  332. }
  333. module.exports = {
  334. buildUsersSubscriptionViewModel,
  335. buildPlansList,
  336. promises: {
  337. buildUsersSubscriptionViewModel: promisify(buildUsersSubscriptionViewModel),
  338. getRedirectToHostedPage,
  339. },
  340. }