SubscriptionController.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973
  1. // @ts-check
  2. const SessionManager = require('../Authentication/SessionManager')
  3. const SubscriptionHandler = require('./SubscriptionHandler')
  4. const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
  5. const LimitationsManager = require('./LimitationsManager')
  6. const RecurlyWrapper = require('./RecurlyWrapper')
  7. const Settings = require('@overleaf/settings')
  8. const logger = require('@overleaf/logger')
  9. const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
  10. const FeaturesUpdater = require('./FeaturesUpdater')
  11. const plansConfig = require('./plansConfig')
  12. const interstitialPaymentConfig = require('./interstitialPaymentConfig')
  13. const GroupPlansData = require('./GroupPlansData')
  14. const V1SubscriptionManager = require('./V1SubscriptionManager')
  15. const AnalyticsManager = require('../Analytics/AnalyticsManager')
  16. const RecurlyEventHandler = require('./RecurlyEventHandler')
  17. const { expressify } = require('@overleaf/promise-utils')
  18. const OError = require('@overleaf/o-error')
  19. const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
  20. const SplitTestHandler = require('../SplitTests/SplitTestHandler')
  21. const SubscriptionHelper = require('./SubscriptionHelper')
  22. const AuthorizationManager = require('../Authorization/AuthorizationManager')
  23. const Modules = require('../../infrastructure/Modules')
  24. const async = require('async')
  25. const { formatCurrencyLocalized } = require('../../util/currency')
  26. const SubscriptionFormatters = require('./SubscriptionFormatters')
  27. const HttpErrorHandler = require('../Errors/HttpErrorHandler')
  28. const { URLSearchParams } = require('url')
  29. const RecurlyClient = require('./RecurlyClient')
  30. const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
  31. const PlansLocator = require('./PlansLocator')
  32. /**
  33. * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
  34. * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
  35. * @import { RecurlySubscriptionChange } from './RecurlyEntities'
  36. * @import { PaymentMethod } from './types'
  37. */
  38. const groupPlanModalOptions = Settings.groupPlanModalOptions
  39. const validGroupPlanModalOptions = {
  40. plan_code: groupPlanModalOptions.plan_codes.map(item => item.code),
  41. currency: groupPlanModalOptions.currencies.map(item => item.code),
  42. size: groupPlanModalOptions.sizes,
  43. usage: groupPlanModalOptions.usages.map(item => item.code),
  44. }
  45. function _getGroupPlanModalDefaults(req, currency) {
  46. function getDefault(param, category, defaultValue) {
  47. const v = req.query && req.query[param]
  48. if (v && validGroupPlanModalOptions[category].includes(v)) {
  49. return v
  50. }
  51. return defaultValue
  52. }
  53. let defaultGroupPlanModalCurrency = 'USD'
  54. if (validGroupPlanModalOptions.currency.includes(currency)) {
  55. defaultGroupPlanModalCurrency = currency
  56. }
  57. return {
  58. plan_code: getDefault('plan', 'plan_code', 'collaborator'),
  59. size: getDefault('number', 'size', '2'),
  60. currency: getDefault('currency', 'currency', defaultGroupPlanModalCurrency),
  61. usage: getDefault('usage', 'usage', 'enterprise'),
  62. }
  63. }
  64. function _plansBanners({ geoPricingLATAMTestVariant, countryCode }) {
  65. const showLATAMBanner =
  66. geoPricingLATAMTestVariant === 'latam' &&
  67. ['MX', 'CO', 'CL', 'PE'].includes(countryCode)
  68. const showInrGeoBanner = countryCode === 'IN'
  69. const showBrlGeoBanner = countryCode === 'BR'
  70. return { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner }
  71. }
  72. async function plansPage(req, res) {
  73. const websiteRedesignPlansAssignment =
  74. await SplitTestHandler.promises.getAssignment(
  75. req,
  76. res,
  77. 'website-redesign-plans'
  78. )
  79. if (websiteRedesignPlansAssignment.variant !== 'default') {
  80. const queryParamString = new URLSearchParams(req.query)?.toString()
  81. const queryParamForRedirect = queryParamString ? '?' + queryParamString : ''
  82. if (websiteRedesignPlansAssignment.variant === 'new-design') {
  83. return res.redirect(
  84. 302,
  85. '/user/subscription/plans-2' + queryParamForRedirect
  86. )
  87. } else if (websiteRedesignPlansAssignment.variant === 'light-design') {
  88. return res.redirect(
  89. 302,
  90. '/user/subscription/plans-3' + queryParamForRedirect
  91. )
  92. }
  93. }
  94. const language = req.i18n.language || 'en'
  95. const plans = SubscriptionViewModelBuilder.buildPlansList()
  96. const { currency, countryCode, geoPricingLATAMTestVariant } =
  97. await _getRecommendedCurrency(req, res)
  98. const latamCountryBannerDetails = await getLatamCountryBannerDetails(req, res)
  99. const groupPlanModalDefaults = _getGroupPlanModalDefaults(req, currency)
  100. const currentView = 'annual'
  101. const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } = _plansBanners(
  102. {
  103. geoPricingLATAMTestVariant,
  104. countryCode,
  105. }
  106. )
  107. const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
  108. req,
  109. res,
  110. 'local-ccy-format-v2'
  111. )
  112. const formatCurrency =
  113. localCcyAssignment.variant === 'enabled'
  114. ? formatCurrencyLocalized
  115. : SubscriptionHelper.formatCurrencyDefault
  116. res.render('subscriptions/plans', {
  117. title: 'plans_and_pricing',
  118. currentView,
  119. plans,
  120. itm_content: req.query?.itm_content,
  121. itm_referrer: req.query?.itm_referrer,
  122. itm_campaign: 'plans',
  123. language,
  124. formatCurrency,
  125. recommendedCurrency: currency,
  126. plansConfig,
  127. groupPlans: GroupPlansData,
  128. groupPlanModalOptions,
  129. groupPlanModalDefaults,
  130. initialLocalizedGroupPrice:
  131. SubscriptionHelper.generateInitialLocalizedGroupPrice(
  132. currency ?? 'USD',
  133. language,
  134. formatCurrency
  135. ),
  136. showInrGeoBanner,
  137. showBrlGeoBanner,
  138. showLATAMBanner,
  139. latamCountryBannerDetails,
  140. countryCode,
  141. websiteRedesignPlansVariant: 'default',
  142. })
  143. }
  144. async function plansPageLightDesign(req, res) {
  145. const splitTestActive = await SplitTestHandler.promises.isSplitTestActive(
  146. 'website-redesign-plans'
  147. )
  148. if (!splitTestActive && req.query.preview !== 'true') {
  149. return res.redirect(302, '/user/subscription/plans')
  150. }
  151. const { currency, countryCode, geoPricingLATAMTestVariant } =
  152. await _getRecommendedCurrency(req, res)
  153. const language = req.i18n.language || 'en'
  154. const currentView = 'annual'
  155. const plans = SubscriptionViewModelBuilder.buildPlansList()
  156. const groupPlanModalDefaults = _getGroupPlanModalDefaults(req, currency)
  157. const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
  158. req,
  159. res,
  160. 'local-ccy-format-v2'
  161. )
  162. const formatCurrency =
  163. localCcyAssignment.variant === 'enabled'
  164. ? formatCurrencyLocalized
  165. : SubscriptionHelper.formatCurrencyDefault
  166. const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } = _plansBanners(
  167. {
  168. geoPricingLATAMTestVariant,
  169. countryCode,
  170. }
  171. )
  172. const latamCountryBannerDetails = await getLatamCountryBannerDetails(req, res)
  173. res.render('subscriptions/plans-light-design', {
  174. title: 'plans_and_pricing',
  175. currentView,
  176. plans,
  177. itm_content: req.query?.itm_content,
  178. itm_referrer: req.query?.itm_referrer,
  179. itm_campaign: 'plans',
  180. language,
  181. formatCurrency,
  182. recommendedCurrency: currency,
  183. plansConfig,
  184. groupPlans: GroupPlansData,
  185. groupPlanModalOptions,
  186. groupPlanModalDefaults,
  187. initialLocalizedGroupPrice:
  188. SubscriptionHelper.generateInitialLocalizedGroupPrice(
  189. currency ?? 'USD',
  190. language,
  191. formatCurrency
  192. ),
  193. showLATAMBanner,
  194. showInrGeoBanner,
  195. showBrlGeoBanner,
  196. latamCountryBannerDetails,
  197. countryCode,
  198. websiteRedesignPlansVariant: 'light-design',
  199. })
  200. }
  201. function formatGroupPlansDataForDash() {
  202. return {
  203. plans: [...groupPlanModalOptions.plan_codes],
  204. sizes: [...groupPlanModalOptions.sizes],
  205. usages: [...groupPlanModalOptions.usages],
  206. priceByUsageTypeAndSize: JSON.parse(JSON.stringify(GroupPlansData)),
  207. }
  208. }
  209. async function userSubscriptionPage(req, res) {
  210. const user = SessionManager.getSessionUser(req.session)
  211. const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
  212. req,
  213. res,
  214. 'local-ccy-format-v2'
  215. )
  216. await SplitTestHandler.promises.getAssignment(req, res, 'ai-add-on')
  217. // Populates splitTestVariants with a value for the split test name and allows
  218. // Pug to read it
  219. await SplitTestHandler.promises.getAssignment(
  220. req,
  221. res,
  222. 'bootstrap-5-subscription'
  223. )
  224. const results =
  225. await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  226. user,
  227. req.i18n.language,
  228. localCcyAssignment.variant === 'enabled'
  229. ? SubscriptionFormatters.formatPriceLocalized
  230. : SubscriptionFormatters.formatPriceDefault
  231. )
  232. const {
  233. personalSubscription,
  234. memberGroupSubscriptions,
  235. managedGroupSubscriptions,
  236. currentInstitutionsWithLicence,
  237. managedInstitutions,
  238. managedPublishers,
  239. } = results
  240. const hasSubscription =
  241. await LimitationsManager.promises.userHasV1OrV2Subscription(user)
  242. const userCanExtendTrial = (
  243. await Modules.promises.hooks.fire('userCanExtendTrial', user)
  244. )?.[0]
  245. const fromPlansPage = req.query.hasSubscription
  246. const plansData =
  247. SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
  248. personalSubscription?.plan
  249. )
  250. AnalyticsManager.recordEventForSession(req.session, 'subscription-page-view')
  251. const groupPlansDataForDash = formatGroupPlansDataForDash()
  252. // display the Group Settings button only to admins of group subscriptions with either/or the Managed Users or Group SSO feature available
  253. let groupSettingsEnabledFor
  254. try {
  255. const managedGroups = await async.filter(
  256. managedGroupSubscriptions || [],
  257. async subscription => {
  258. const managedUsersResults = await Modules.promises.hooks.fire(
  259. 'hasManagedUsersFeature',
  260. subscription
  261. )
  262. const groupSSOResults = await Modules.promises.hooks.fire(
  263. 'hasGroupSSOFeature',
  264. subscription
  265. )
  266. const isGroupAdmin =
  267. (subscription.admin_id._id || subscription.admin_id).toString() ===
  268. user._id.toString()
  269. return (
  270. (managedUsersResults?.[0] === true ||
  271. groupSSOResults?.[0] === true) &&
  272. isGroupAdmin
  273. )
  274. }
  275. )
  276. groupSettingsEnabledFor = managedGroups.map(subscription =>
  277. subscription._id.toString()
  278. )
  279. } catch (error) {
  280. logger.error(
  281. { err: error },
  282. 'Failed to list groups with group settings enabled'
  283. )
  284. }
  285. const data = {
  286. title: 'your_subscription',
  287. plans: plansData?.plans,
  288. planCodesChangingAtTermEnd: plansData?.planCodesChangingAtTermEnd,
  289. user,
  290. hasSubscription,
  291. fromPlansPage,
  292. personalSubscription,
  293. userCanExtendTrial,
  294. memberGroupSubscriptions,
  295. managedGroupSubscriptions,
  296. managedInstitutions,
  297. managedPublishers,
  298. currentInstitutionsWithLicence,
  299. groupPlans: groupPlansDataForDash,
  300. groupSettingsEnabledFor,
  301. isManagedAccount: !!req.managedBy,
  302. userRestrictions: Array.from(req.userRestrictions || []),
  303. }
  304. res.render('subscriptions/dashboard-react', data)
  305. }
  306. async function interstitialPaymentPage(req, res) {
  307. const websiteRedesignPlansAssignment =
  308. await SplitTestHandler.promises.getAssignment(
  309. req,
  310. res,
  311. 'website-redesign-plans'
  312. )
  313. let template = 'subscriptions/interstitial-payment'
  314. if (websiteRedesignPlansAssignment.variant === 'new-design') {
  315. return await Modules.promises.hooks.fire(
  316. 'interstitialPaymentPageNewDesign',
  317. req,
  318. res
  319. )
  320. } else if (websiteRedesignPlansAssignment.variant === 'light-design') {
  321. template = 'subscriptions/interstitial-payment-light-design'
  322. }
  323. const user = SessionManager.getSessionUser(req.session)
  324. const { recommendedCurrency, countryCode, geoPricingLATAMTestVariant } =
  325. await _getRecommendedCurrency(req, res)
  326. const latamCountryBannerDetails = await getLatamCountryBannerDetails(req, res)
  327. const hasSubscription =
  328. await LimitationsManager.promises.userHasV1OrV2Subscription(user)
  329. const showSkipLink = req.query?.skipLink === 'true'
  330. if (hasSubscription) {
  331. res.redirect('/user/subscription?hasSubscription=true')
  332. } else {
  333. const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } =
  334. _plansBanners({
  335. geoPricingLATAMTestVariant,
  336. countryCode,
  337. })
  338. const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
  339. req,
  340. res,
  341. 'local-ccy-format-v2'
  342. )
  343. res.render(template, {
  344. title: 'subscribe',
  345. itm_content: req.query?.itm_content,
  346. itm_campaign: req.query?.itm_campaign,
  347. itm_referrer: req.query?.itm_referrer,
  348. recommendedCurrency,
  349. interstitialPaymentConfig,
  350. showSkipLink,
  351. formatCurrency:
  352. localCcyAssignment.variant === 'enabled'
  353. ? formatCurrencyLocalized
  354. : SubscriptionHelper.formatCurrencyDefault,
  355. showCurrencyAndPaymentMethods: localCcyAssignment.variant === 'enabled',
  356. showInrGeoBanner,
  357. showBrlGeoBanner,
  358. showLATAMBanner,
  359. latamCountryBannerDetails,
  360. skipLinkTarget: req.session?.postCheckoutRedirect || '/project',
  361. websiteRedesignPlansVariant: websiteRedesignPlansAssignment.variant,
  362. countryCode,
  363. })
  364. }
  365. }
  366. async function successfulSubscription(req, res) {
  367. const user = SessionManager.getSessionUser(req.session)
  368. const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
  369. req,
  370. res,
  371. 'local-ccy-format-v2'
  372. )
  373. const { personalSubscription } =
  374. await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
  375. user,
  376. req.i18n.language,
  377. localCcyAssignment.variant === 'enabled'
  378. ? SubscriptionFormatters.formatPriceLocalized
  379. : SubscriptionFormatters.formatPriceDefault
  380. )
  381. const postCheckoutRedirect = req.session?.postCheckoutRedirect
  382. if (!personalSubscription) {
  383. res.redirect('/user/subscription/plans')
  384. } else {
  385. await SplitTestHandler.promises.getAssignment(
  386. req,
  387. res,
  388. 'bootstrap-5-subscription'
  389. )
  390. res.render('subscriptions/successful-subscription-react', {
  391. title: 'thank_you',
  392. personalSubscription,
  393. postCheckoutRedirect,
  394. user,
  395. })
  396. }
  397. }
  398. function cancelSubscription(req, res, next) {
  399. const user = SessionManager.getSessionUser(req.session)
  400. logger.debug({ userId: user._id }, 'canceling subscription')
  401. SubscriptionHandler.cancelSubscription(user, function (err) {
  402. if (err) {
  403. OError.tag(err, 'something went wrong canceling subscription', {
  404. user_id: user._id,
  405. })
  406. return next(err)
  407. }
  408. // Note: this redirect isn't used in the main flow as the redirection is
  409. // handled by Angular
  410. res.redirect('/user/subscription/canceled')
  411. })
  412. }
  413. /**
  414. * @returns {Promise<void>}
  415. */
  416. async function canceledSubscription(req, res, next) {
  417. await SplitTestHandler.promises.getAssignment(
  418. req,
  419. res,
  420. 'bootstrap-5-subscription'
  421. )
  422. return res.render('subscriptions/canceled-subscription-react', {
  423. title: 'subscription_canceled',
  424. user: SessionManager.getSessionUser(req.session),
  425. })
  426. }
  427. function cancelV1Subscription(req, res, next) {
  428. const userId = SessionManager.getLoggedInUserId(req.session)
  429. logger.debug({ userId }, 'canceling v1 subscription')
  430. V1SubscriptionManager.cancelV1Subscription(userId, function (err) {
  431. if (err) {
  432. OError.tag(err, 'something went wrong canceling v1 subscription', {
  433. userId,
  434. })
  435. return next(err)
  436. }
  437. res.redirect('/user/subscription')
  438. })
  439. }
  440. async function previewAddonPurchase(req, res) {
  441. const userId = SessionManager.getLoggedInUserId(req.session)
  442. const addOnCode = req.params.addOnCode
  443. if (addOnCode !== AI_ADD_ON_CODE) {
  444. return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
  445. }
  446. const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
  447. let subscriptionChange
  448. try {
  449. subscriptionChange =
  450. await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode)
  451. } catch (err) {
  452. if (err instanceof DuplicateAddOnError) {
  453. return HttpErrorHandler.badRequest(
  454. req,
  455. res,
  456. `Subscription already has add-on "${addOnCode}"`
  457. )
  458. }
  459. throw err
  460. }
  461. const subscription = subscriptionChange.subscription
  462. const addOn = await RecurlyClient.promises.getAddOn(
  463. subscription.planCode,
  464. addOnCode
  465. )
  466. /** @type {SubscriptionChangePreview} */
  467. const changePreview = makeChangePreview(
  468. {
  469. type: 'add-on-purchase',
  470. addOn: {
  471. code: addOn.code,
  472. name: addOn.name,
  473. },
  474. },
  475. subscriptionChange,
  476. paymentMethod
  477. )
  478. res.render('subscriptions/preview-change', { changePreview })
  479. }
  480. async function purchaseAddon(req, res, next) {
  481. const user = SessionManager.getSessionUser(req.session)
  482. const addOnCode = req.params.addOnCode
  483. // currently we only support having a quantity of 1
  484. const quantity = 1
  485. // currently we only support one add-on, the Ai add-on
  486. if (addOnCode !== AI_ADD_ON_CODE) {
  487. return res.sendStatus(404)
  488. }
  489. logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons')
  490. try {
  491. await SubscriptionHandler.promises.purchaseAddon(user, addOnCode, quantity)
  492. return res.sendStatus(200)
  493. } catch (err) {
  494. if (err instanceof DuplicateAddOnError) {
  495. HttpErrorHandler.badRequest(
  496. req,
  497. res,
  498. 'Your subscription already includes this add-on',
  499. { addon: addOnCode }
  500. )
  501. } else {
  502. if (err instanceof Error) {
  503. OError.tag(err, 'something went wrong purchasing add-ons', {
  504. user_id: user._id,
  505. addOnCode,
  506. })
  507. }
  508. return next(err)
  509. }
  510. }
  511. }
  512. async function removeAddon(req, res, next) {
  513. const user = SessionManager.getSessionUser(req.session)
  514. const addOnCode = req.params.addOnCode
  515. if (addOnCode !== AI_ADD_ON_CODE) {
  516. return res.sendStatus(404)
  517. }
  518. logger.debug({ userId: user._id, addOnCode }, 'removing add-ons')
  519. try {
  520. await SubscriptionHandler.promises.removeAddon(user, addOnCode)
  521. res.sendStatus(200)
  522. } catch (err) {
  523. if (err instanceof AddOnNotPresentError) {
  524. HttpErrorHandler.badRequest(
  525. req,
  526. res,
  527. 'Your subscription does not contain the requested add-on',
  528. { addon: addOnCode }
  529. )
  530. } else {
  531. if (err instanceof Error) {
  532. OError.tag(err, 'something went wrong removing add-ons', {
  533. user_id: user._id,
  534. addOnCode,
  535. })
  536. }
  537. return next(err)
  538. }
  539. }
  540. }
  541. async function previewSubscription(req, res, next) {
  542. const planCode = req.query.planCode
  543. if (!planCode) {
  544. return HttpErrorHandler.notFound(req, res, 'Missing plan code')
  545. }
  546. const plan = await RecurlyClient.promises.getPlan(planCode)
  547. const userId = SessionManager.getLoggedInUserId(req.session)
  548. const subscriptionChange =
  549. await SubscriptionHandler.promises.previewSubscriptionChange(
  550. userId,
  551. planCode
  552. )
  553. const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
  554. const changePreview = makeChangePreview(
  555. {
  556. type: 'premium-subscription',
  557. plan: { code: plan.code, name: plan.name },
  558. },
  559. subscriptionChange,
  560. paymentMethod
  561. )
  562. res.render('subscriptions/preview-change', { changePreview })
  563. }
  564. function updateSubscription(req, res, next) {
  565. const origin = req && req.query ? req.query.origin : null
  566. const user = SessionManager.getSessionUser(req.session)
  567. const planCode = req.body.plan_code
  568. if (planCode == null) {
  569. const err = new Error('plan_code is not defined')
  570. logger.warn(
  571. { userId: user._id, err, planCode, origin, body: req.body },
  572. '[Subscription] error in updateSubscription form'
  573. )
  574. return next(err)
  575. }
  576. logger.debug({ planCode, userId: user._id }, 'updating subscription')
  577. SubscriptionHandler.updateSubscription(user, planCode, null, function (err) {
  578. if (err) {
  579. OError.tag(err, 'something went wrong updating subscription', {
  580. user_id: user._id,
  581. })
  582. return next(err)
  583. }
  584. res.redirect('/user/subscription')
  585. })
  586. }
  587. function cancelPendingSubscriptionChange(req, res, next) {
  588. const user = SessionManager.getSessionUser(req.session)
  589. logger.debug({ userId: user._id }, 'canceling pending subscription change')
  590. SubscriptionHandler.cancelPendingSubscriptionChange(user, function (err) {
  591. if (err) {
  592. OError.tag(
  593. err,
  594. 'something went wrong canceling pending subscription change',
  595. {
  596. user_id: user._id,
  597. }
  598. )
  599. return next(err)
  600. }
  601. res.redirect('/user/subscription')
  602. })
  603. }
  604. function updateAccountEmailAddress(req, res, next) {
  605. const user = SessionManager.getSessionUser(req.session)
  606. RecurlyWrapper.updateAccountEmailAddress(
  607. user._id,
  608. user.email,
  609. function (error) {
  610. if (error) {
  611. return next(error)
  612. }
  613. res.sendStatus(200)
  614. }
  615. )
  616. }
  617. function reactivateSubscription(req, res, next) {
  618. const user = SessionManager.getSessionUser(req.session)
  619. logger.debug({ userId: user._id }, 'reactivating subscription')
  620. try {
  621. if (req.isManagedGroupAdmin) {
  622. // allow admins to reactivate subscriptions
  623. } else {
  624. // otherwise require the user to have the reactivate-subscription permission
  625. req.assertPermission('reactivate-subscription')
  626. }
  627. } catch (error) {
  628. return next(error)
  629. }
  630. SubscriptionHandler.reactivateSubscription(user, function (err) {
  631. if (err) {
  632. OError.tag(err, 'something went wrong reactivating subscription', {
  633. user_id: user._id,
  634. })
  635. return next(err)
  636. }
  637. res.redirect('/user/subscription')
  638. })
  639. }
  640. function recurlyCallback(req, res, next) {
  641. logger.debug({ data: req.body }, 'received recurly callback')
  642. const event = Object.keys(req.body)[0]
  643. const eventData = req.body[event]
  644. RecurlyEventHandler.sendRecurlyAnalyticsEvent(event, eventData).catch(error =>
  645. logger.error(
  646. { err: error },
  647. 'Failed to process analytics event on Recurly webhook'
  648. )
  649. )
  650. if (
  651. [
  652. 'new_subscription_notification',
  653. 'updated_subscription_notification',
  654. 'expired_subscription_notification',
  655. ].includes(event)
  656. ) {
  657. const recurlySubscription = eventData.subscription
  658. SubscriptionHandler.syncSubscription(
  659. recurlySubscription,
  660. { ip: req.ip },
  661. function (err) {
  662. if (err) {
  663. return next(err)
  664. }
  665. res.sendStatus(200)
  666. }
  667. )
  668. } else if (event === 'billing_info_updated_notification') {
  669. const recurlyAccountCode = eventData.account.account_code
  670. SubscriptionHandler.attemptPaypalInvoiceCollection(
  671. recurlyAccountCode,
  672. function (err) {
  673. if (err) {
  674. return next(err)
  675. }
  676. res.sendStatus(200)
  677. }
  678. )
  679. } else {
  680. res.sendStatus(200)
  681. }
  682. }
  683. async function extendTrial(req, res) {
  684. const user = SessionManager.getSessionUser(req.session)
  685. const { subscription } =
  686. await LimitationsManager.promises.userHasV2Subscription(user)
  687. const allowed = (
  688. await Modules.promises.hooks.fire('userCanExtendTrial', user)
  689. )?.[0]
  690. if (!allowed) {
  691. logger.warn({ userId: user._id }, 'user can not extend trial')
  692. return res.sendStatus(403)
  693. }
  694. try {
  695. await SubscriptionHandler.promises.extendTrial(subscription, 14)
  696. AnalyticsManager.recordEventForSession(
  697. req.session,
  698. 'subscription-trial-extended'
  699. )
  700. } catch (error) {
  701. return res.sendStatus(500)
  702. }
  703. res.sendStatus(200)
  704. }
  705. function recurlyNotificationParser(req, res, next) {
  706. let xml = ''
  707. req.on('data', chunk => (xml += chunk))
  708. req.on('end', () =>
  709. RecurlyWrapper._parseXml(xml, function (error, body) {
  710. if (error) {
  711. return next(error)
  712. }
  713. req.body = body
  714. next()
  715. })
  716. )
  717. }
  718. async function refreshUserFeatures(req, res) {
  719. const { user_id: userId } = req.params
  720. await FeaturesUpdater.promises.refreshFeatures(userId, 'acceptance-test')
  721. res.sendStatus(200)
  722. }
  723. async function redirectToHostedPage(req, res) {
  724. const userId = SessionManager.getLoggedInUserId(req.session)
  725. const { pageType } = req.params
  726. const url =
  727. await SubscriptionViewModelBuilder.promises.getRedirectToHostedPage(
  728. userId,
  729. pageType
  730. )
  731. logger.warn({ userId, pageType }, 'redirecting to recurly hosted page')
  732. res.redirect(url)
  733. }
  734. async function _getRecommendedCurrency(req, res) {
  735. const userId = SessionManager.getLoggedInUserId(req.session)
  736. let ip = req.ip
  737. if (
  738. req.query?.ip &&
  739. (await AuthorizationManager.promises.isUserSiteAdmin(userId))
  740. ) {
  741. ip = req.query.ip
  742. }
  743. const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(ip)
  744. const countryCode = currencyLookup.countryCode
  745. let recommendedCurrency = currencyLookup.currencyCode
  746. const assignmentLATAM = await SplitTestHandler.promises.getAssignment(
  747. req,
  748. res,
  749. 'geo-pricing-latam-v2'
  750. )
  751. if (
  752. ['MXN', 'COP', 'CLP', 'PEN'].includes(recommendedCurrency) &&
  753. assignmentLATAM?.variant === 'default'
  754. ) {
  755. recommendedCurrency = GeoIpLookup.DEFAULT_CURRENCY_CODE
  756. }
  757. let currency = null
  758. const queryCurrency = req.query.currency?.toUpperCase()
  759. if (queryCurrency && GeoIpLookup.isValidCurrencyParam(queryCurrency)) {
  760. currency = queryCurrency
  761. } else if (recommendedCurrency) {
  762. currency = recommendedCurrency
  763. }
  764. return {
  765. currency,
  766. recommendedCurrency,
  767. countryCode,
  768. geoPricingLATAMTestVariant: assignmentLATAM?.variant,
  769. }
  770. }
  771. async function getLatamCountryBannerDetails(req, res) {
  772. const userId = SessionManager.getLoggedInUserId(req.session)
  773. let ip = req.ip
  774. if (
  775. req.query?.ip &&
  776. (await AuthorizationManager.promises.isUserSiteAdmin(userId))
  777. ) {
  778. ip = req.query.ip
  779. }
  780. const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(ip)
  781. const countryCode = currencyLookup.countryCode
  782. const latamCountryBannerDetails = {}
  783. switch (countryCode) {
  784. case `MX`:
  785. latamCountryBannerDetails.latamCountryFlag = '🇲🇽'
  786. latamCountryBannerDetails.country = 'Mexico'
  787. latamCountryBannerDetails.discount = '25%'
  788. latamCountryBannerDetails.currency = 'Mexican Pesos'
  789. break
  790. case `CO`:
  791. latamCountryBannerDetails.latamCountryFlag = '🇨🇴'
  792. latamCountryBannerDetails.country = 'Colombia'
  793. latamCountryBannerDetails.discount = '60%'
  794. latamCountryBannerDetails.currency = 'Colombian Pesos'
  795. break
  796. case `CL`:
  797. latamCountryBannerDetails.latamCountryFlag = '🇨🇱'
  798. latamCountryBannerDetails.country = 'Chile'
  799. latamCountryBannerDetails.discount = '30%'
  800. latamCountryBannerDetails.currency = 'Chilean Pesos'
  801. break
  802. case `PE`:
  803. latamCountryBannerDetails.latamCountryFlag = '🇵🇪'
  804. latamCountryBannerDetails.country = 'Peru'
  805. latamCountryBannerDetails.currency = 'Peruvian Soles'
  806. latamCountryBannerDetails.discount = '40%'
  807. break
  808. }
  809. return latamCountryBannerDetails
  810. }
  811. /**
  812. * Build a subscription change preview for display purposes
  813. *
  814. * @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
  815. * @param {RecurlySubscriptionChange} subscriptionChange The subscription change object coming from Recurly
  816. * @param {PaymentMethod} paymentMethod The payment method associated to the user
  817. * @return {SubscriptionChangePreview}
  818. */
  819. function makeChangePreview(
  820. subscriptionChangeDescription,
  821. subscriptionChange,
  822. paymentMethod
  823. ) {
  824. const subscription = subscriptionChange.subscription
  825. const nextPlan = PlansLocator.findLocalPlanInSettings(
  826. subscriptionChange.nextPlanCode
  827. )
  828. return {
  829. change: subscriptionChangeDescription,
  830. currency: subscription.currency,
  831. immediateCharge: subscriptionChange.immediateCharge,
  832. paymentMethod: paymentMethod.toString(),
  833. nextPlan: {
  834. annual: nextPlan.annual ?? false,
  835. },
  836. nextInvoice: {
  837. date: subscription.periodEnd.toISOString(),
  838. plan: {
  839. name: subscriptionChange.nextPlanName,
  840. amount: subscriptionChange.nextPlanPrice,
  841. },
  842. addOns: subscriptionChange.nextAddOns.map(addOn => ({
  843. code: addOn.code,
  844. name: addOn.name,
  845. quantity: addOn.quantity,
  846. unitAmount: addOn.unitPrice,
  847. amount: addOn.preTaxTotal,
  848. })),
  849. subtotal: subscriptionChange.subtotal,
  850. tax: {
  851. rate: subscription.taxRate,
  852. amount: subscriptionChange.tax,
  853. },
  854. total: subscriptionChange.total,
  855. },
  856. }
  857. }
  858. module.exports = {
  859. plansPage: expressify(plansPage),
  860. plansPageLightDesign: expressify(plansPageLightDesign),
  861. userSubscriptionPage: expressify(userSubscriptionPage),
  862. interstitialPaymentPage: expressify(interstitialPaymentPage),
  863. successfulSubscription: expressify(successfulSubscription),
  864. cancelSubscription,
  865. canceledSubscription: expressify(canceledSubscription),
  866. cancelV1Subscription,
  867. previewSubscription: expressify(previewSubscription),
  868. updateSubscription,
  869. cancelPendingSubscriptionChange,
  870. updateAccountEmailAddress,
  871. reactivateSubscription,
  872. recurlyCallback,
  873. extendTrial: expressify(extendTrial),
  874. recurlyNotificationParser,
  875. refreshUserFeatures: expressify(refreshUserFeatures),
  876. redirectToHostedPage: expressify(redirectToHostedPage),
  877. plansBanners: _plansBanners,
  878. previewAddonPurchase: expressify(previewAddonPurchase),
  879. purchaseAddon,
  880. removeAddon,
  881. promises: {
  882. getRecommendedCurrency: _getRecommendedCurrency,
  883. getLatamCountryBannerDetails,
  884. },
  885. }