SubscriptionController.js 27 KB

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