SubscriptionController.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868
  1. // @ts-check
  2. const SessionManager = require('../Authentication/SessionManager')
  3. const SubscriptionHandler = require('./SubscriptionHandler')
  4. const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
  5. const LimitationsManager = require('./LimitationsManager')
  6. const RecurlyWrapper = require('./RecurlyWrapper')
  7. const Settings = require('@overleaf/settings')
  8. const logger = require('@overleaf/logger')
  9. const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
  10. const FeaturesUpdater = require('./FeaturesUpdater')
  11. const GroupPlansData = require('./GroupPlansData')
  12. const V1SubscriptionManager = require('./V1SubscriptionManager')
  13. const AnalyticsManager = require('../Analytics/AnalyticsManager')
  14. const RecurlyEventHandler = require('./RecurlyEventHandler')
  15. const { expressify } = require('@overleaf/promise-utils')
  16. const OError = require('@overleaf/o-error')
  17. const {
  18. DuplicateAddOnError,
  19. AddOnNotPresentError,
  20. PaymentActionRequiredError,
  21. } = require('./Errors')
  22. const SplitTestHandler = require('../SplitTests/SplitTestHandler')
  23. const AuthorizationManager = require('../Authorization/AuthorizationManager')
  24. const Modules = require('../../infrastructure/Modules')
  25. const async = require('async')
  26. const HttpErrorHandler = require('../Errors/HttpErrorHandler')
  27. const RecurlyClient = require('./RecurlyClient')
  28. const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities')
  29. const PlansLocator = require('./PlansLocator')
  30. const PaymentProviderEntities = require('./PaymentProviderEntities')
  31. const { User } = require('../../models/User')
  32. const UserGetter = require('../User/UserGetter')
  33. /**
  34. * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
  35. * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
  36. * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities'
  37. * @import { PaymentMethod } from './types'
  38. */
  39. const groupPlanModalOptions = Settings.groupPlanModalOptions
  40. function formatGroupPlansDataForDash() {
  41. return {
  42. plans: [...groupPlanModalOptions.plan_codes],
  43. sizes: [...groupPlanModalOptions.sizes],
  44. usages: [...groupPlanModalOptions.usages],
  45. priceByUsageTypeAndSize: JSON.parse(JSON.stringify(GroupPlansData)),
  46. }
  47. }
  48. async function userSubscriptionPage(req, res) {
  49. const user = SessionManager.getSessionUser(req.session)
  50. await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription')
  51. const groupPricingDiscount = await SplitTestHandler.promises.getAssignment(
  52. req,
  53. res,
  54. 'group-discount-10'
  55. )
  56. const showGroupDiscount = groupPricingDiscount.variant === 'enabled'
  57. const results =
  58. await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  59. user,
  60. req.i18n.language
  61. )
  62. const {
  63. personalSubscription,
  64. memberGroupSubscriptions,
  65. managedGroupSubscriptions,
  66. currentInstitutionsWithLicence,
  67. managedInstitutions,
  68. managedPublishers,
  69. } = results
  70. const { hasSubscription } =
  71. await LimitationsManager.promises.userHasSubscription(user)
  72. const userCanExtendTrial = (
  73. await Modules.promises.hooks.fire('userCanExtendTrial', user)
  74. )?.[0]
  75. const fromPlansPage = req.query.hasSubscription
  76. const plansData =
  77. SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
  78. personalSubscription?.plan
  79. )
  80. AnalyticsManager.recordEventForSession(req.session, 'subscription-page-view')
  81. const groupPlansDataForDash = formatGroupPlansDataForDash()
  82. // display the Group settings button only to admins of group subscriptions with either/or the Managed Users or Group SSO feature available
  83. let groupSettingsEnabledFor
  84. try {
  85. const managedGroups = await async.filter(
  86. managedGroupSubscriptions || [],
  87. async subscription => {
  88. const managedUsersResults = await Modules.promises.hooks.fire(
  89. 'hasManagedUsersFeature',
  90. subscription
  91. )
  92. const groupSSOResults = await Modules.promises.hooks.fire(
  93. 'hasGroupSSOFeature',
  94. subscription
  95. )
  96. const isGroupAdmin =
  97. (subscription.admin_id._id || subscription.admin_id).toString() ===
  98. user._id.toString()
  99. return (
  100. (managedUsersResults?.[0] === true ||
  101. groupSSOResults?.[0] === true) &&
  102. isGroupAdmin
  103. )
  104. }
  105. )
  106. groupSettingsEnabledFor = managedGroups.map(subscription =>
  107. subscription._id.toString()
  108. )
  109. } catch (error) {
  110. logger.error(
  111. { err: error },
  112. 'Failed to list groups with group settings enabled'
  113. )
  114. }
  115. let groupSettingsAdvertisedFor
  116. try {
  117. const managedGroups = await async.filter(
  118. managedGroupSubscriptions || [],
  119. async subscription => {
  120. const managedUsersResults = await Modules.promises.hooks.fire(
  121. 'hasManagedUsersFeatureOnNonProfessionalPlan',
  122. subscription
  123. )
  124. const groupSSOResults = await Modules.promises.hooks.fire(
  125. 'hasGroupSSOFeatureOnNonProfessionalPlan',
  126. subscription
  127. )
  128. const isGroupAdmin =
  129. (subscription.admin_id._id || subscription.admin_id).toString() ===
  130. user._id.toString()
  131. const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
  132. return (
  133. (managedUsersResults?.[0] === true ||
  134. groupSSOResults?.[0] === true) &&
  135. isGroupAdmin &&
  136. plan?.canUseFlexibleLicensing
  137. )
  138. }
  139. )
  140. groupSettingsAdvertisedFor = managedGroups.map(subscription =>
  141. subscription._id.toString()
  142. )
  143. } catch (error) {
  144. logger.error(
  145. { err: error },
  146. 'Failed to list groups with group settings enabled for advertising'
  147. )
  148. }
  149. const {
  150. isPremium: hasAiAssistViaWritefull,
  151. premiumSource: aiAssistViaWritefullSource,
  152. } = await UserGetter.promises.getWritefullData(user._id)
  153. const data = {
  154. title: 'your_subscription',
  155. plans: plansData?.plans,
  156. planCodesChangingAtTermEnd: plansData?.planCodesChangingAtTermEnd,
  157. user,
  158. hasSubscription,
  159. fromPlansPage,
  160. personalSubscription,
  161. userCanExtendTrial,
  162. memberGroupSubscriptions,
  163. managedGroupSubscriptions,
  164. managedInstitutions,
  165. managedPublishers,
  166. showGroupDiscount,
  167. currentInstitutionsWithLicence,
  168. canUseFlexibleLicensing:
  169. personalSubscription?.plan?.canUseFlexibleLicensing,
  170. groupPlans: groupPlansDataForDash,
  171. groupSettingsAdvertisedFor,
  172. groupSettingsEnabledFor,
  173. isManagedAccount: !!req.managedBy,
  174. userRestrictions: Array.from(req.userRestrictions || []),
  175. hasAiAssistViaWritefull,
  176. aiAssistViaWritefullSource,
  177. }
  178. res.render('subscriptions/dashboard-react', data)
  179. }
  180. async function successfulSubscription(req, res) {
  181. const user = SessionManager.getSessionUser(req.session)
  182. if (!user) {
  183. throw new Error('User is not logged in')
  184. }
  185. const { personalSubscription } =
  186. await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  187. user,
  188. req.i18n.language
  189. )
  190. const postCheckoutRedirect = req.session?.postCheckoutRedirect
  191. if (!personalSubscription) {
  192. res.redirect('/user/subscription/plans')
  193. } else {
  194. const userInDb = await User.findById(user._id, {
  195. _id: 1,
  196. features: 1,
  197. })
  198. if (!userInDb) {
  199. throw new Error('User not found')
  200. }
  201. res.render('subscriptions/successful-subscription-react', {
  202. title: 'thank_you',
  203. personalSubscription,
  204. postCheckoutRedirect,
  205. user: {
  206. _id: user._id,
  207. features: userInDb.features,
  208. },
  209. })
  210. }
  211. }
  212. async function pauseSubscription(req, res, next) {
  213. const user = SessionManager.getSessionUser(req.session)
  214. const pauseCycles = req.params.pauseCycles
  215. if (!('pauseCycles' in req.params)) {
  216. return HttpErrorHandler.badRequest(
  217. req,
  218. res,
  219. `Pausing subscription requires a 'pauseCycles' argument with number of billing cycles to pause for`
  220. )
  221. }
  222. if (pauseCycles < 0) {
  223. return HttpErrorHandler.badRequest(
  224. req,
  225. res,
  226. `'pauseCycles' should be a number of billing cycles to pause for, or 0 to cancel a pending pause`
  227. )
  228. }
  229. logger.debug(
  230. { userId: user._id },
  231. `pausing subscription for ${pauseCycles} billing cycles`
  232. )
  233. try {
  234. await SubscriptionHandler.promises.pauseSubscription(user, pauseCycles)
  235. const { subscription } =
  236. await LimitationsManager.promises.userHasSubscription(user)
  237. AnalyticsManager.recordEventForUserInBackground(
  238. user._id,
  239. 'subscription-pause-scheduled',
  240. {
  241. pause_length: pauseCycles,
  242. plan_code: subscription?.planCode,
  243. subscriptionId: subscription?.recurlySubscription_id,
  244. }
  245. )
  246. return res.sendStatus(200)
  247. } catch (err) {
  248. if (err instanceof Error) {
  249. OError.tag(err, 'something went wrong pausing subscription', {
  250. user_id: user._id,
  251. })
  252. }
  253. return next(err)
  254. }
  255. }
  256. async function resumeSubscription(req, res, next) {
  257. const user = SessionManager.getSessionUser(req.session)
  258. logger.debug({ userId: user._id }, `resuming subscription`)
  259. try {
  260. await SubscriptionHandler.promises.resumeSubscription(user)
  261. return res.sendStatus(200)
  262. } catch (err) {
  263. if (err instanceof Error) {
  264. OError.tag(err, 'something went wrong resuming subscription', {
  265. user_id: user._id,
  266. })
  267. }
  268. return next(err)
  269. }
  270. }
  271. function cancelSubscription(req, res, next) {
  272. const user = SessionManager.getSessionUser(req.session)
  273. logger.debug({ userId: user._id }, 'canceling subscription')
  274. SubscriptionHandler.cancelSubscription(user, function (err) {
  275. if (err) {
  276. OError.tag(err, 'something went wrong canceling subscription', {
  277. user_id: user._id,
  278. })
  279. return next(err)
  280. }
  281. // Note: this redirect isn't used in the main flow as the redirection is
  282. // handled by Angular
  283. res.redirect('/user/subscription/canceled')
  284. })
  285. }
  286. /**
  287. * @returns {Promise<void>}
  288. */
  289. async function canceledSubscription(req, res, next) {
  290. return res.render('subscriptions/canceled-subscription-react', {
  291. title: 'subscription_canceled',
  292. user: SessionManager.getSessionUser(req.session),
  293. })
  294. }
  295. function cancelV1Subscription(req, res, next) {
  296. const userId = SessionManager.getLoggedInUserId(req.session)
  297. logger.debug({ userId }, 'canceling v1 subscription')
  298. V1SubscriptionManager.cancelV1Subscription(userId, function (err) {
  299. if (err) {
  300. OError.tag(err, 'something went wrong canceling v1 subscription', {
  301. userId,
  302. })
  303. return next(err)
  304. }
  305. res.redirect('/user/subscription')
  306. })
  307. }
  308. async function previewAddonPurchase(req, res) {
  309. const userId = SessionManager.getLoggedInUserId(req.session)
  310. const addOnCode = req.params.addOnCode
  311. const purchaseReferrer = req.query.purchaseReferrer
  312. if (addOnCode !== AI_ADD_ON_CODE) {
  313. return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
  314. }
  315. /** @type {PaymentMethod[]} */
  316. const paymentMethod = await Modules.promises.hooks.fire(
  317. 'getPaymentMethod',
  318. userId
  319. )
  320. let subscriptionChange
  321. try {
  322. subscriptionChange =
  323. await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode)
  324. const { isPremium: hasAiAssistViaWritefull } =
  325. await UserGetter.promises.getWritefullData(userId)
  326. const isAiUpgrade =
  327. PaymentProviderEntities.subscriptionChangeIsAiAssistUpgrade(
  328. subscriptionChange
  329. )
  330. if (hasAiAssistViaWritefull && isAiUpgrade) {
  331. return res.redirect(
  332. '/user/subscription?redirect-reason=writefull-entitled'
  333. )
  334. }
  335. } catch (err) {
  336. if (err instanceof DuplicateAddOnError) {
  337. return res.redirect('/user/subscription?redirect-reason=double-buy')
  338. }
  339. throw err
  340. }
  341. const subscription = subscriptionChange.subscription
  342. const addOn = await RecurlyClient.promises.getAddOn(
  343. subscription.planCode,
  344. addOnCode
  345. )
  346. /** @type {SubscriptionChangePreview} */
  347. const changePreview = makeChangePreview(
  348. {
  349. type: 'add-on-purchase',
  350. addOn: {
  351. code: addOn.code,
  352. name: addOn.name,
  353. },
  354. },
  355. subscriptionChange,
  356. paymentMethod[0]
  357. )
  358. await SplitTestHandler.promises.getAssignment(
  359. req,
  360. res,
  361. 'overleaf-assist-bundle'
  362. )
  363. res.render('subscriptions/preview-change', {
  364. changePreview,
  365. purchaseReferrer,
  366. })
  367. }
  368. async function purchaseAddon(req, res, next) {
  369. const user = SessionManager.getSessionUser(req.session)
  370. const addOnCode = req.params.addOnCode
  371. // currently we only support having a quantity of 1
  372. const quantity = 1
  373. // currently we only support one add-on, the Ai add-on
  374. if (addOnCode !== AI_ADD_ON_CODE) {
  375. return res.sendStatus(404)
  376. }
  377. logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons')
  378. try {
  379. // set a restore point in the case of a failed payment for the upgrade (Recurly only)
  380. await SubscriptionHandler.promises.setSubscriptionRestorePoint(user._id)
  381. await SubscriptionHandler.promises.purchaseAddon(
  382. user._id,
  383. addOnCode,
  384. quantity
  385. )
  386. } catch (err) {
  387. if (err instanceof DuplicateAddOnError) {
  388. HttpErrorHandler.badRequest(
  389. req,
  390. res,
  391. 'Your subscription already includes this add-on',
  392. { addon: addOnCode }
  393. )
  394. } else if (err instanceof PaymentActionRequiredError) {
  395. return res.status(402).json({
  396. message: 'Payment action required',
  397. clientSecret: err.info.clientSecret,
  398. })
  399. } else {
  400. if (err instanceof Error) {
  401. OError.tag(err, 'something went wrong purchasing add-ons', {
  402. user_id: user._id,
  403. addOnCode,
  404. })
  405. }
  406. return next(err)
  407. }
  408. }
  409. try {
  410. await FeaturesUpdater.promises.refreshFeatures(user._id, 'add-on-purchase')
  411. } catch (err) {
  412. logger.error({ err }, 'Failed to refresh features after add-on purchase')
  413. }
  414. return res.sendStatus(200)
  415. }
  416. async function removeAddon(req, res, next) {
  417. const user = SessionManager.getSessionUser(req.session)
  418. const addOnCode = req.params.addOnCode
  419. if (addOnCode !== AI_ADD_ON_CODE) {
  420. return res.sendStatus(404)
  421. }
  422. logger.debug({ userId: user._id, addOnCode }, 'removing add-ons')
  423. try {
  424. await SubscriptionHandler.promises.removeAddon(user._id, addOnCode)
  425. res.sendStatus(200)
  426. } catch (err) {
  427. if (err instanceof AddOnNotPresentError) {
  428. HttpErrorHandler.badRequest(
  429. req,
  430. res,
  431. 'Your subscription does not contain the requested add-on',
  432. { addon: addOnCode }
  433. )
  434. } else {
  435. if (err instanceof Error) {
  436. OError.tag(err, 'something went wrong removing add-ons', {
  437. user_id: user._id,
  438. addOnCode,
  439. })
  440. }
  441. return next(err)
  442. }
  443. }
  444. }
  445. async function previewSubscription(req, res, next) {
  446. const planCode = req.query.planCode
  447. if (!planCode) {
  448. return HttpErrorHandler.notFound(req, res, 'Missing plan code')
  449. }
  450. // TODO: use PaymentService to fetch plan information
  451. const plan = await RecurlyClient.promises.getPlan(planCode)
  452. const userId = SessionManager.getLoggedInUserId(req.session)
  453. const subscriptionChange =
  454. await SubscriptionHandler.promises.previewSubscriptionChange(
  455. userId,
  456. planCode
  457. )
  458. /** @type {PaymentMethod[]} */
  459. const paymentMethod = await Modules.promises.hooks.fire(
  460. 'getPaymentMethod',
  461. userId
  462. )
  463. const changePreview = makeChangePreview(
  464. {
  465. type: 'premium-subscription',
  466. plan: { code: plan.code, name: plan.name },
  467. },
  468. subscriptionChange,
  469. paymentMethod[0]
  470. )
  471. res.render('subscriptions/preview-change', { changePreview })
  472. }
  473. function cancelPendingSubscriptionChange(req, res, next) {
  474. const user = SessionManager.getSessionUser(req.session)
  475. logger.debug({ userId: user._id }, 'canceling pending subscription change')
  476. SubscriptionHandler.cancelPendingSubscriptionChange(user, function (err) {
  477. if (err) {
  478. OError.tag(
  479. err,
  480. 'something went wrong canceling pending subscription change',
  481. {
  482. user_id: user._id,
  483. }
  484. )
  485. return next(err)
  486. }
  487. res.redirect('/user/subscription')
  488. })
  489. }
  490. async function updateAccountEmailAddress(req, res, next) {
  491. const user = SessionManager.getSessionUser(req.session)
  492. try {
  493. await Modules.promises.hooks.fire(
  494. 'updateAccountEmailAddress',
  495. user._id,
  496. user.email
  497. )
  498. return res.sendStatus(200)
  499. } catch (error) {
  500. return next(error)
  501. }
  502. }
  503. function reactivateSubscription(req, res, next) {
  504. const user = SessionManager.getSessionUser(req.session)
  505. logger.debug({ userId: user._id }, 'reactivating subscription')
  506. try {
  507. if (req.isManagedGroupAdmin) {
  508. // allow admins to reactivate subscriptions
  509. } else {
  510. // otherwise require the user to have the reactivate-subscription permission
  511. req.assertPermission('reactivate-subscription')
  512. }
  513. } catch (error) {
  514. return next(error)
  515. }
  516. SubscriptionHandler.reactivateSubscription(user, function (err) {
  517. if (err) {
  518. OError.tag(err, 'something went wrong reactivating subscription', {
  519. user_id: user._id,
  520. })
  521. return next(err)
  522. }
  523. res.redirect('/user/subscription')
  524. })
  525. }
  526. function recurlyCallback(req, res, next) {
  527. logger.debug({ data: req.body }, 'received recurly callback')
  528. const event = Object.keys(req.body)[0]
  529. const eventData = req.body[event]
  530. RecurlyEventHandler.sendRecurlyAnalyticsEvent(event, eventData).catch(error =>
  531. logger.error(
  532. { err: error },
  533. 'Failed to process analytics event on Recurly webhook'
  534. )
  535. )
  536. // this is a recurly only case which is required since Recurly does not have a reliable way to check credit info pre-upgrade purchase
  537. if (event === 'failed_payment_notification') {
  538. if (!Settings.planReverts?.enabled) {
  539. return res.sendStatus(200)
  540. }
  541. SubscriptionHandler.getSubscriptionRestorePoint(
  542. eventData.transaction.subscription_id,
  543. function (err, lastSubscription) {
  544. if (err) {
  545. return next(err)
  546. }
  547. // if theres no restore point it could be a failed renewal, or no restore set. Either way it will be handled through dunning automatically
  548. if (!lastSubscription || !lastSubscription?.planCode) {
  549. return res.sendStatus(200)
  550. }
  551. SubscriptionHandler.revertPlanChange(
  552. eventData.transaction.subscription_id,
  553. lastSubscription,
  554. function (err) {
  555. if (err) {
  556. return next(err)
  557. }
  558. return res.sendStatus(200)
  559. }
  560. )
  561. }
  562. )
  563. } else if (
  564. [
  565. 'new_subscription_notification',
  566. 'updated_subscription_notification',
  567. 'expired_subscription_notification',
  568. 'subscription_paused_notification',
  569. 'subscription_resumed_notification',
  570. ].includes(event)
  571. ) {
  572. const recurlySubscription = eventData.subscription
  573. SubscriptionHandler.syncSubscription(
  574. recurlySubscription,
  575. { ip: req.ip },
  576. function (err) {
  577. if (err) {
  578. return next(err)
  579. }
  580. res.sendStatus(200)
  581. }
  582. )
  583. } else if (event === 'billing_info_updated_notification') {
  584. const recurlyAccountCode = eventData.account.account_code
  585. SubscriptionHandler.attemptPaypalInvoiceCollection(
  586. recurlyAccountCode,
  587. function (err) {
  588. if (err) {
  589. return next(err)
  590. }
  591. res.sendStatus(200)
  592. }
  593. )
  594. } else {
  595. res.sendStatus(200)
  596. }
  597. }
  598. async function extendTrial(req, res) {
  599. const user = SessionManager.getSessionUser(req.session)
  600. const { subscription } =
  601. await LimitationsManager.promises.userHasSubscription(user)
  602. const allowed = (
  603. await Modules.promises.hooks.fire('userCanExtendTrial', user)
  604. )?.[0]
  605. if (!allowed) {
  606. logger.warn({ userId: user._id }, 'user can not extend trial')
  607. return res.sendStatus(403)
  608. }
  609. try {
  610. await SubscriptionHandler.promises.extendTrial(subscription, 14)
  611. AnalyticsManager.recordEventForSession(
  612. req.session,
  613. 'subscription-trial-extended'
  614. )
  615. } catch (error) {
  616. return res.sendStatus(500)
  617. }
  618. res.sendStatus(200)
  619. }
  620. function recurlyNotificationParser(req, res, next) {
  621. let xml = ''
  622. req.on('data', chunk => (xml += chunk))
  623. req.on('end', () =>
  624. RecurlyWrapper._parseXml(xml, function (error, body) {
  625. if (error) {
  626. return next(error)
  627. }
  628. req.body = body
  629. next()
  630. })
  631. )
  632. }
  633. async function refreshUserFeatures(req, res) {
  634. const { user_id: userId } = req.params
  635. await FeaturesUpdater.promises.refreshFeatures(userId, 'acceptance-test')
  636. res.sendStatus(200)
  637. }
  638. async function getRecommendedCurrency(req, res) {
  639. const userId = SessionManager.getLoggedInUserId(req.session)
  640. let ip = req.ip
  641. if (
  642. req.query?.ip &&
  643. (await AuthorizationManager.promises.isUserSiteAdmin(userId))
  644. ) {
  645. ip = req.query.ip
  646. }
  647. const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(ip)
  648. const countryCode = currencyLookup.countryCode
  649. const recommendedCurrency = currencyLookup.currencyCode
  650. let currency = null
  651. const queryCurrency = req.query.currency?.toUpperCase()
  652. if (queryCurrency && GeoIpLookup.isValidCurrencyParam(queryCurrency)) {
  653. currency = queryCurrency
  654. } else if (recommendedCurrency) {
  655. currency = recommendedCurrency
  656. }
  657. return {
  658. currency,
  659. recommendedCurrency,
  660. countryCode,
  661. }
  662. }
  663. async function getLatamCountryBannerDetails(req, res) {
  664. const userId = SessionManager.getLoggedInUserId(req.session)
  665. let ip = req.ip
  666. if (
  667. req.query?.ip &&
  668. (await AuthorizationManager.promises.isUserSiteAdmin(userId))
  669. ) {
  670. ip = req.query.ip
  671. }
  672. const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(ip)
  673. const countryCode = currencyLookup.countryCode
  674. const latamCountryBannerDetails = {}
  675. switch (countryCode) {
  676. case `MX`:
  677. latamCountryBannerDetails.latamCountryFlag = '🇲🇽'
  678. latamCountryBannerDetails.country = 'Mexico'
  679. latamCountryBannerDetails.discount = '25%'
  680. latamCountryBannerDetails.currency = 'Mexican Pesos'
  681. break
  682. case `CO`:
  683. latamCountryBannerDetails.latamCountryFlag = '🇨🇴'
  684. latamCountryBannerDetails.country = 'Colombia'
  685. latamCountryBannerDetails.discount = '60%'
  686. latamCountryBannerDetails.currency = 'Colombian Pesos'
  687. break
  688. case `CL`:
  689. latamCountryBannerDetails.latamCountryFlag = '🇨🇱'
  690. latamCountryBannerDetails.country = 'Chile'
  691. latamCountryBannerDetails.discount = '30%'
  692. latamCountryBannerDetails.currency = 'Chilean Pesos'
  693. break
  694. case `PE`:
  695. latamCountryBannerDetails.latamCountryFlag = '🇵🇪'
  696. latamCountryBannerDetails.country = 'Peru'
  697. latamCountryBannerDetails.currency = 'Peruvian Soles'
  698. latamCountryBannerDetails.discount = '40%'
  699. break
  700. }
  701. return latamCountryBannerDetails
  702. }
  703. /**
  704. * There are two sets of group plans: legacy plans and consolidated plans,
  705. * and their naming conventions differ.
  706. * This helper method computes the name of legacy group plans to ensure
  707. * consistency with the naming of consolidated group plans.
  708. *
  709. * @param {string} planName
  710. * @param {string} planCode
  711. * @return {string}
  712. */
  713. function getPlanNameForDisplay(planName, planCode) {
  714. const match = planCode.match(
  715. /^group_(collaborator|professional)_\d+_(enterprise|educational)$/
  716. )
  717. if (!match) return planName
  718. const [, type, category] = match
  719. const prefix = type === 'collaborator' ? 'Standard' : 'Professional'
  720. const suffix = category === 'educational' ? ' Educational' : ''
  721. return `Overleaf ${prefix} Group${suffix}`
  722. }
  723. /**
  724. * Build a subscription change preview for display purposes
  725. *
  726. * @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
  727. * @param {PaymentProviderSubscriptionChange} subscriptionChange The subscription change object coming from Recurly
  728. * @param {PaymentMethod} [paymentMethod] The payment method associated to the user
  729. * @return {SubscriptionChangePreview}
  730. */
  731. function makeChangePreview(
  732. subscriptionChangeDescription,
  733. subscriptionChange,
  734. paymentMethod
  735. ) {
  736. const subscription = subscriptionChange.subscription
  737. const nextPlan = PlansLocator.findLocalPlanInSettings(
  738. subscriptionChange.nextPlanCode
  739. )
  740. return {
  741. change: subscriptionChangeDescription,
  742. currency: subscription.currency,
  743. immediateCharge: { ...subscriptionChange.immediateCharge },
  744. paymentMethod: paymentMethod?.toString(),
  745. netTerms: subscription.netTerms,
  746. nextPlan: {
  747. annual: nextPlan.annual ?? false,
  748. },
  749. nextInvoice: {
  750. date: subscription.periodEnd.toISOString(),
  751. plan: {
  752. name: getPlanNameForDisplay(
  753. subscriptionChange.nextPlanName,
  754. subscriptionChange.nextPlanCode
  755. ),
  756. amount: subscriptionChange.nextPlanPrice,
  757. },
  758. addOns: subscriptionChange.nextAddOns.map(addOn => ({
  759. code: addOn.code,
  760. name: addOn.name,
  761. quantity: addOn.quantity,
  762. unitAmount: addOn.unitPrice,
  763. amount: addOn.preTaxTotal,
  764. })),
  765. subtotal: subscriptionChange.subtotal,
  766. tax: {
  767. rate: subscription.taxRate,
  768. amount: subscriptionChange.tax,
  769. },
  770. total: subscriptionChange.total,
  771. },
  772. }
  773. }
  774. module.exports = {
  775. userSubscriptionPage: expressify(userSubscriptionPage),
  776. successfulSubscription: expressify(successfulSubscription),
  777. cancelSubscription,
  778. pauseSubscription,
  779. resumeSubscription,
  780. canceledSubscription: expressify(canceledSubscription),
  781. cancelV1Subscription,
  782. previewSubscription: expressify(previewSubscription),
  783. cancelPendingSubscriptionChange,
  784. updateAccountEmailAddress: expressify(updateAccountEmailAddress),
  785. reactivateSubscription,
  786. recurlyCallback,
  787. extendTrial: expressify(extendTrial),
  788. recurlyNotificationParser,
  789. refreshUserFeatures: expressify(refreshUserFeatures),
  790. previewAddonPurchase: expressify(previewAddonPurchase),
  791. purchaseAddon,
  792. removeAddon,
  793. makeChangePreview,
  794. getRecommendedCurrency,
  795. getLatamCountryBannerDetails,
  796. getPlanNameForDisplay,
  797. }