SubscriptionController.mjs 33 KB

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