SubscriptionController.mjs 35 KB

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