SubscriptionController.mjs 32 KB

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