SubscriptionGroupHandler.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. const { callbackify } = require('util')
  2. const _ = require('lodash')
  3. const OError = require('@overleaf/o-error')
  4. const SubscriptionUpdater = require('./SubscriptionUpdater')
  5. const SubscriptionLocator = require('./SubscriptionLocator')
  6. const SubscriptionController = require('./SubscriptionController')
  7. const SubscriptionHelper = require('./SubscriptionHelper')
  8. const { Subscription } = require('../../models/Subscription')
  9. const { User } = require('../../models/User')
  10. const RecurlyClient = require('./RecurlyClient')
  11. const PlansLocator = require('./PlansLocator')
  12. const TeamInvitesHandler = require('./TeamInvitesHandler')
  13. const GroupPlansData = require('./GroupPlansData')
  14. const Modules = require('../../infrastructure/Modules')
  15. const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities')
  16. const {
  17. ManuallyCollectedError,
  18. PendingChangeError,
  19. InactiveError,
  20. HasPastDueInvoiceError,
  21. HasNoAdditionalLicenseWhenManuallyCollectedError,
  22. } = require('./Errors')
  23. const EmailHelper = require('../Helpers/EmailHelper')
  24. const { InvalidEmailError } = require('../Errors/Errors')
  25. async function removeUserFromGroup(subscriptionId, userIdToRemove, auditLog) {
  26. await SubscriptionUpdater.promises.removeUserFromGroup(
  27. subscriptionId,
  28. userIdToRemove,
  29. auditLog
  30. )
  31. }
  32. async function replaceUserReferencesInGroups(oldId, newId) {
  33. await Subscription.updateOne({ admin_id: oldId }, { admin_id: newId }).exec()
  34. await _replaceInArray(Subscription, 'manager_ids', oldId, newId)
  35. await _replaceInArray(Subscription, 'member_ids', oldId, newId)
  36. }
  37. async function isUserPartOfGroup(userId, subscriptionId) {
  38. const subscription =
  39. await SubscriptionLocator.promises.getSubscriptionByMemberIdAndId(
  40. userId,
  41. subscriptionId
  42. )
  43. return !!subscription
  44. }
  45. async function getTotalConfirmedUsersInGroup(subscriptionId) {
  46. const subscription =
  47. await SubscriptionLocator.promises.getSubscription(subscriptionId)
  48. return subscription?.member_ids?.length
  49. }
  50. async function _replaceInArray(model, property, oldValue, newValue) {
  51. // Mongo won't let us pull and addToSet in the same query, so do it in
  52. // two. Note we need to add first, since the query is based on the old user.
  53. const query = {}
  54. query[property] = oldValue
  55. const setNewValue = {}
  56. setNewValue[property] = newValue
  57. const setOldValue = {}
  58. setOldValue[property] = oldValue
  59. await model.updateMany(query, { $addToSet: setNewValue })
  60. await model.updateMany(query, { $pull: setOldValue })
  61. }
  62. async function ensureFlexibleLicensingEnabled(plan) {
  63. if (!plan?.canUseFlexibleLicensing) {
  64. throw new Error('The group plan does not support flexible licensing')
  65. }
  66. }
  67. async function ensureSubscriptionIsActive(subscription) {
  68. if (SubscriptionHelper.getPaidSubscriptionState(subscription) !== 'active') {
  69. throw new InactiveError('The subscription is not active', {
  70. subscriptionId: subscription._id.toString(),
  71. })
  72. }
  73. }
  74. async function ensureSubscriptionCollectionMethodIsNotManual(
  75. paymentProviderSubscription
  76. ) {
  77. if (paymentProviderSubscription.isCollectionMethodManual) {
  78. throw new ManuallyCollectedError(
  79. 'This subscription is being collected manually',
  80. {
  81. subscription_id: paymentProviderSubscription.id,
  82. }
  83. )
  84. }
  85. }
  86. async function ensureSubscriptionHasNoPendingChanges(
  87. paymentProviderSubscription
  88. ) {
  89. if (paymentProviderSubscription.pendingChange) {
  90. throw new PendingChangeError('This subscription has a pending change', {
  91. subscription_id: paymentProviderSubscription.id,
  92. })
  93. }
  94. }
  95. async function ensureSubscriptionHasNoPastDueInvoice(subscription) {
  96. const [paymentRecord] = await Modules.promises.hooks.fire(
  97. 'getPaymentFromRecord',
  98. subscription
  99. )
  100. if (paymentRecord.account.hasPastDueInvoice) {
  101. throw new HasPastDueInvoiceError(
  102. 'This subscription has a past due invoice',
  103. {
  104. subscriptionId: subscription._id.toString(),
  105. }
  106. )
  107. }
  108. }
  109. async function ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
  110. paymentProviderSubscription
  111. ) {
  112. if (
  113. paymentProviderSubscription.isCollectionMethodManual &&
  114. !paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)
  115. ) {
  116. throw new HasNoAdditionalLicenseWhenManuallyCollectedError(
  117. 'This subscription is being collected manually has no "additional-license" add-on',
  118. {
  119. subscription_id: paymentProviderSubscription.id,
  120. }
  121. )
  122. }
  123. }
  124. async function getUsersGroupSubscriptionDetails(userId) {
  125. const subscription =
  126. await SubscriptionLocator.promises.getUsersSubscription(userId)
  127. if (!subscription) {
  128. throw new Error('No subscription was found')
  129. }
  130. if (!subscription.groupPlan) {
  131. throw new Error('User subscription is not a group plan')
  132. }
  133. const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
  134. const response = await Modules.promises.hooks.fire(
  135. 'getPaymentFromRecord',
  136. subscription
  137. )
  138. const { subscription: paymentProviderSubscription } = response[0]
  139. return {
  140. userId,
  141. subscription,
  142. paymentProviderSubscription,
  143. plan,
  144. }
  145. }
  146. async function checkBillingInfoExistence(paymentProviderSubscription, userId) {
  147. // Verify the billing info only if the collection method is not manual (e.g. automatic)
  148. if (!paymentProviderSubscription.isCollectionMethodManual) {
  149. // Check if the user has missing billing details
  150. await Modules.promises.hooks.fire('getPaymentMethod', userId)
  151. }
  152. }
  153. async function _addSeatsSubscriptionChange(userId, adding) {
  154. const { subscription, paymentProviderSubscription, plan } =
  155. await getUsersGroupSubscriptionDetails(userId)
  156. await ensureFlexibleLicensingEnabled(plan)
  157. await ensureSubscriptionIsActive(subscription)
  158. await ensureSubscriptionHasNoPendingChanges(paymentProviderSubscription)
  159. await checkBillingInfoExistence(paymentProviderSubscription, userId)
  160. await ensureSubscriptionHasNoPastDueInvoice(subscription)
  161. const currentAddonQuantity =
  162. paymentProviderSubscription.addOns.find(
  163. addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE
  164. )?.quantity ?? 0
  165. // Keeps only the new total quantity of addon
  166. const nextAddonQuantity = currentAddonQuantity + adding
  167. let changeRequest
  168. if (paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) {
  169. // Not providing a custom price as once the subscription is locked
  170. // to an add-on at a given price, it will use it for subsequent payments
  171. changeRequest = paymentProviderSubscription.getRequestForAddOnUpdate(
  172. MEMBERS_LIMIT_ADD_ON_CODE,
  173. nextAddonQuantity
  174. )
  175. } else {
  176. let unitPrice
  177. const pattern =
  178. /^group_(collaborator|professional)_(2|3|4|5|10|20|50)_(educational|enterprise)$/
  179. const [, planCode, size, usage] = plan.planCode.match(pattern)
  180. const currency = paymentProviderSubscription.currency
  181. const planPriceInCents =
  182. GroupPlansData[usage][planCode][currency][size].price_in_cents
  183. const legacyUnitPriceInCents =
  184. GroupPlansData[usage][planCode][currency][size]
  185. .additional_license_legacy_price_in_cents
  186. if (
  187. _shouldUseLegacyPricing(
  188. paymentProviderSubscription.planPrice,
  189. planPriceInCents / 100,
  190. usage,
  191. size
  192. )
  193. ) {
  194. unitPrice = legacyUnitPriceInCents / 100
  195. }
  196. changeRequest = paymentProviderSubscription.getRequestForAddOnPurchase(
  197. MEMBERS_LIMIT_ADD_ON_CODE,
  198. nextAddonQuantity,
  199. unitPrice
  200. )
  201. }
  202. return {
  203. changeRequest,
  204. currentAddonQuantity,
  205. paymentProviderSubscription,
  206. }
  207. }
  208. function _shouldUseLegacyPricing(
  209. actualPlanPrice,
  210. currentPlanPrice,
  211. usage,
  212. size
  213. ) {
  214. // For small educational groups (5 or fewer members)
  215. // 2025 pricing is cheaper than legacy pricing
  216. if (size <= 5 && usage === 'educational') {
  217. return currentPlanPrice < actualPlanPrice
  218. }
  219. // For all other scenarios
  220. // 2025 pricing is more expensive than legacy pricing
  221. return currentPlanPrice > actualPlanPrice
  222. }
  223. async function previewAddSeatsSubscriptionChange(userId, adding) {
  224. const { changeRequest, currentAddonQuantity } =
  225. await _addSeatsSubscriptionChange(userId, adding)
  226. const response = await Modules.promises.hooks.fire(
  227. 'previewSubscriptionChangeRequest',
  228. changeRequest
  229. )
  230. const subscriptionChange = response[0]
  231. const subscriptionChangePreview = SubscriptionController.makeChangePreview(
  232. {
  233. type: 'add-on-update',
  234. addOn: {
  235. code: MEMBERS_LIMIT_ADD_ON_CODE,
  236. quantity: subscriptionChange.nextAddOns.find(
  237. addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE
  238. ).quantity,
  239. prevQuantity: currentAddonQuantity,
  240. },
  241. },
  242. subscriptionChange
  243. )
  244. return subscriptionChangePreview
  245. }
  246. async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
  247. const { changeRequest, paymentProviderSubscription } =
  248. await _addSeatsSubscriptionChange(userId, adding)
  249. if (paymentProviderSubscription.isCollectionMethodManual) {
  250. await updateSubscriptionPaymentTerms(paymentProviderSubscription, poNumber)
  251. }
  252. await Modules.promises.hooks.fire(
  253. 'applySubscriptionChangeRequestAndSync',
  254. changeRequest,
  255. userId
  256. )
  257. return { adding }
  258. }
  259. async function updateSubscriptionPaymentTerms(
  260. paymentProviderSubscription,
  261. poNumber
  262. ) {
  263. if (paymentProviderSubscription.service?.includes('stripe')) {
  264. // TODO: Implement Stripe payment terms update
  265. throw new OError(
  266. 'Updating payment terms is not supported for Stripe subscriptions',
  267. {
  268. subscriptionId: paymentProviderSubscription.id,
  269. }
  270. )
  271. }
  272. const [termsAndConditions] = await Modules.promises.hooks.fire(
  273. 'generateTermsAndConditions',
  274. { currency: paymentProviderSubscription.currency, poNumber }
  275. )
  276. const updateRequest = poNumber
  277. ? paymentProviderSubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
  278. poNumber,
  279. termsAndConditions
  280. )
  281. : paymentProviderSubscription.getRequestForTermsAndConditionsUpdate(
  282. termsAndConditions
  283. )
  284. await RecurlyClient.promises.updateSubscriptionDetails(updateRequest)
  285. }
  286. async function getGroupPlanUpgradePreview(ownerId) {
  287. const preview = await Modules.promises.hooks.fire(
  288. 'previewGroupPlanUpgrade',
  289. ownerId
  290. )
  291. const { subscriptionChange, paymentMethod } = preview[0]
  292. return SubscriptionController.makeChangePreview(
  293. {
  294. type: 'group-plan-upgrade',
  295. prevPlan: {
  296. name: SubscriptionController.getPlanNameForDisplay(
  297. subscriptionChange.subscription.planName,
  298. subscriptionChange.subscription.planCode
  299. ),
  300. },
  301. },
  302. subscriptionChange,
  303. paymentMethod
  304. )
  305. }
  306. async function upgradeGroupPlan(ownerId) {
  307. await Modules.promises.hooks.fire('upgradeGroupPlan', ownerId)
  308. }
  309. async function updateGroupMembersBulk(
  310. inviterId,
  311. subscriptionId,
  312. emailList,
  313. options = {}
  314. ) {
  315. const { removeMembersNotIncluded, commit } = options
  316. // remove duplications and empty values
  317. emailList = _.uniq(_.compact(emailList))
  318. const invalidEmails = emailList.filter(
  319. email => !EmailHelper.parseEmail(email)
  320. )
  321. if (invalidEmails.length > 0) {
  322. throw new InvalidEmailError('email not valid', {
  323. invalidEmails,
  324. })
  325. }
  326. const subscription = await Subscription.findOne({
  327. _id: subscriptionId,
  328. }).exec()
  329. const existingUserData = await User.find(
  330. {
  331. _id: { $in: subscription.member_ids },
  332. },
  333. { _id: 1, email: 1, 'emails.email': 1 }
  334. ).exec()
  335. const existingUsers = existingUserData.map(user => ({
  336. _id: user._id,
  337. emails: user.emails?.map(user => user.email),
  338. }))
  339. const currentMemberEmails = _.flatten(
  340. existingUsers
  341. .filter(userData => userData.emails?.length > 0)
  342. .map(user => user.emails)
  343. )
  344. const currentInvites =
  345. subscription.teamInvites?.map(invite => invite.email) || []
  346. if (subscription.invited_emails?.length > 0) {
  347. currentInvites.push(...subscription.invited_emails)
  348. }
  349. const invitesToSend = _.difference(
  350. emailList,
  351. currentMemberEmails.concat(currentInvites)
  352. )
  353. let membersToRemove
  354. let invitesToRevoke
  355. let newTotalCount
  356. if (!removeMembersNotIncluded) {
  357. membersToRemove = []
  358. invitesToRevoke = []
  359. newTotalCount =
  360. existingUsers.length + currentInvites.length + invitesToSend.length
  361. } else {
  362. membersToRemove = []
  363. for (const existingUser of existingUsers) {
  364. if (_.intersection(existingUser.emails, emailList).length === 0) {
  365. membersToRemove.push(existingUser._id)
  366. }
  367. }
  368. const invitesToMaintain = _.intersection(emailList, currentInvites)
  369. invitesToRevoke = _.difference(currentInvites, invitesToMaintain)
  370. newTotalCount =
  371. existingUsers.length -
  372. membersToRemove.length +
  373. invitesToMaintain.length +
  374. invitesToSend.length
  375. }
  376. const result = {
  377. emailsToSendInvite: invitesToSend,
  378. emailsToRevokeInvite: invitesToRevoke,
  379. membersToRemove,
  380. currentMemberCount: existingUsers.length,
  381. newTotalCount,
  382. membersLimit: subscription.membersLimit,
  383. }
  384. if (commit) {
  385. if (newTotalCount > subscription.membersLimit) {
  386. const { currentMemberCount, newTotalCount, membersLimit } = result
  387. throw new OError('limit reached', {
  388. currentMemberCount,
  389. newTotalCount,
  390. membersLimit,
  391. })
  392. }
  393. for (const email of invitesToSend) {
  394. await TeamInvitesHandler.promises.createInvite(
  395. inviterId,
  396. subscription,
  397. email
  398. )
  399. }
  400. for (const email of invitesToRevoke) {
  401. await TeamInvitesHandler.promises.revokeInvite(
  402. inviterId,
  403. subscription,
  404. email
  405. )
  406. }
  407. for (const user of membersToRemove) {
  408. await removeUserFromGroup(subscription._id, user._id, {
  409. initiatorId: inviterId,
  410. })
  411. }
  412. }
  413. return result
  414. }
  415. module.exports = {
  416. removeUserFromGroup: callbackify(removeUserFromGroup),
  417. replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
  418. ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
  419. ensureSubscriptionIsActive: callbackify(ensureSubscriptionIsActive),
  420. ensureSubscriptionCollectionMethodIsNotManual: callbackify(
  421. ensureSubscriptionCollectionMethodIsNotManual
  422. ),
  423. ensureSubscriptionHasNoPendingChanges: callbackify(
  424. ensureSubscriptionHasNoPendingChanges
  425. ),
  426. ensureSubscriptionHasNoPastDueInvoice: callbackify(
  427. ensureSubscriptionHasNoPastDueInvoice
  428. ),
  429. ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual:
  430. callbackify(
  431. ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual
  432. ),
  433. getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
  434. isUserPartOfGroup: callbackify(isUserPartOfGroup),
  435. getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
  436. upgradeGroupPlan: callbackify(upgradeGroupPlan),
  437. checkBillingInfoExistence: callbackify(checkBillingInfoExistence),
  438. updateGroupMembersBulk: callbackify(updateGroupMembersBulk),
  439. promises: {
  440. removeUserFromGroup,
  441. replaceUserReferencesInGroups,
  442. ensureFlexibleLicensingEnabled,
  443. ensureSubscriptionIsActive,
  444. ensureSubscriptionCollectionMethodIsNotManual,
  445. ensureSubscriptionHasNoPendingChanges,
  446. ensureSubscriptionHasNoPastDueInvoice,
  447. ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual,
  448. getTotalConfirmedUsersInGroup,
  449. isUserPartOfGroup,
  450. getUsersGroupSubscriptionDetails,
  451. previewAddSeatsSubscriptionChange,
  452. createAddSeatsSubscriptionChange,
  453. updateSubscriptionPaymentTerms,
  454. getGroupPlanUpgradePreview,
  455. upgradeGroupPlan,
  456. checkBillingInfoExistence,
  457. updateGroupMembersBulk,
  458. },
  459. }