SubscriptionController.mjs 39 KB

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