SubscriptionUpdater.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import { db, ObjectId } from '../../infrastructure/mongodb.js'
  2. import { callbackify } from '@overleaf/promise-utils'
  3. import { Subscription } from '../../models/Subscription.js'
  4. import SubscriptionLocator from './SubscriptionLocator.mjs'
  5. import PlansLocator from './PlansLocator.mjs'
  6. import FeaturesUpdater from './FeaturesUpdater.mjs'
  7. import FeaturesHelper from './FeaturesHelper.mjs'
  8. import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
  9. import { DeletedSubscription } from '../../models/DeletedSubscription.js'
  10. import logger from '@overleaf/logger'
  11. import Features from '../../infrastructure/Features.js'
  12. import UserAuditLogHandler from '../User/UserAuditLogHandler.mjs'
  13. import UserUpdater from '../User/UserUpdater.mjs'
  14. import AccountMappingHelper from '../Analytics/AccountMappingHelper.mjs'
  15. import { SSOConfig } from '../../models/SSOConfig.js'
  16. import mongoose from '../../infrastructure/Mongoose.js'
  17. import Modules from '../../infrastructure/Modules.js'
  18. /**
  19. * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription
  20. * @typedef {import('../../../../types/subscription/dashboard/subscription').PaymentProvider} PaymentProvider
  21. * @typedef {import('../../../../types/group-management/group-audit-log').GroupAuditLog} GroupAuditLog
  22. * @import { AddOn } from '../../../../types/subscription/plan'
  23. */
  24. /**
  25. *
  26. * @param {GroupAuditLog} auditLog
  27. */
  28. async function subscriptionUpdateWithAuditLog(dbFilter, dbUpdate, auditLog) {
  29. const session = await mongoose.startSession()
  30. try {
  31. await session.withTransaction(async () => {
  32. await Subscription.updateOne(dbFilter, dbUpdate, { session }).exec()
  33. await Modules.promises.hooks.fire(
  34. 'addGroupAuditLogEntry',
  35. auditLog,
  36. session
  37. )
  38. })
  39. } finally {
  40. await session.endSession()
  41. }
  42. }
  43. /**
  44. * Change the admin of the given subscription.
  45. *
  46. * If the subscription is a group, add the new admin as manager while keeping
  47. * the old admin. Otherwise, replace the manager.
  48. *
  49. * Validation checks are assumed to have been made:
  50. * * subscription exists
  51. * * user exists
  52. * * user does not have another subscription
  53. * * subscription is not a Recurly subscription
  54. *
  55. * If the subscription is Recurly, we silently do nothing.
  56. */
  57. async function updateAdmin(subscription, adminId) {
  58. const query = {
  59. _id: new ObjectId(subscription._id),
  60. customAccount: true,
  61. }
  62. const update = {
  63. $set: { admin_id: new ObjectId(adminId) },
  64. }
  65. if (subscription.groupPlan) {
  66. update.$addToSet = { manager_ids: new ObjectId(adminId) }
  67. } else {
  68. update.$set.manager_ids = [new ObjectId(adminId)]
  69. }
  70. await Subscription.updateOne(query, update).exec()
  71. }
  72. async function syncSubscription(
  73. recurlySubscription,
  74. adminUserId,
  75. requesterData = {}
  76. ) {
  77. let subscription =
  78. await SubscriptionLocator.promises.getUsersSubscription(adminUserId)
  79. if (subscription == null) {
  80. subscription = await createNewSubscription(adminUserId)
  81. }
  82. await updateSubscriptionFromRecurly(
  83. recurlySubscription,
  84. subscription,
  85. requesterData
  86. )
  87. }
  88. async function addUserToGroup(subscriptionId, userId, auditLog) {
  89. await UserAuditLogHandler.promises.addEntry(
  90. userId,
  91. 'join-group-subscription',
  92. undefined,
  93. undefined,
  94. { subscriptionId }
  95. )
  96. await subscriptionUpdateWithAuditLog(
  97. { _id: subscriptionId },
  98. { $addToSet: { member_ids: userId } },
  99. {
  100. initiatorId: auditLog?.initiatorId,
  101. ipAddress: auditLog?.ipAddress,
  102. groupId: subscriptionId,
  103. operation: 'join-group',
  104. }
  105. )
  106. await FeaturesUpdater.promises.refreshFeatures(userId, 'add-to-group')
  107. await _sendUserGroupPlanCodeUserProperty(userId)
  108. await _sendSubscriptionEvent(
  109. userId,
  110. subscriptionId,
  111. 'group-subscription-joined'
  112. )
  113. }
  114. async function removeUserFromGroup(subscriptionId, userId, auditLog) {
  115. await UserAuditLogHandler.promises.addEntry(
  116. userId,
  117. 'leave-group-subscription',
  118. undefined,
  119. undefined,
  120. { subscriptionId }
  121. )
  122. await subscriptionUpdateWithAuditLog(
  123. { _id: subscriptionId },
  124. { $pull: { member_ids: userId } },
  125. {
  126. initiatorId: auditLog?.initiatorId,
  127. ipAddress: auditLog?.ipAddress,
  128. groupId: subscriptionId,
  129. operation: 'leave-group',
  130. info: { userIdRemoved: userId },
  131. }
  132. )
  133. await Subscription.updateOne(
  134. { _id: subscriptionId },
  135. { $pull: { member_ids: userId } }
  136. ).exec()
  137. const subscription = await Subscription.findById(subscriptionId)
  138. if (subscription.managedUsersEnabled) {
  139. await UserUpdater.promises.updateUser(
  140. { _id: userId },
  141. {
  142. $unset: {
  143. 'enrollment.managedBy': 1,
  144. 'enrollment.enrolledAt': 1,
  145. },
  146. }
  147. )
  148. }
  149. await FeaturesUpdater.promises.refreshFeatures(
  150. userId,
  151. 'remove-user-from-group'
  152. )
  153. await _sendUserGroupPlanCodeUserProperty(userId)
  154. await _sendSubscriptionEvent(
  155. userId,
  156. subscriptionId,
  157. 'group-subscription-left'
  158. )
  159. }
  160. async function removeUserFromAllGroups(userId) {
  161. const subscriptions =
  162. await SubscriptionLocator.promises.getMemberSubscriptions(userId)
  163. if (subscriptions.length === 0) {
  164. return
  165. }
  166. const subscriptionIds = subscriptions.map(sub => sub._id)
  167. const removeOperation = { $pull: { member_ids: userId } }
  168. for (const subscriptionId of subscriptionIds) {
  169. await UserAuditLogHandler.promises.addEntry(
  170. userId,
  171. 'leave-group-subscription',
  172. undefined,
  173. undefined,
  174. { subscriptionId }
  175. )
  176. }
  177. await Subscription.updateMany(
  178. { _id: subscriptionIds },
  179. removeOperation
  180. ).exec()
  181. await FeaturesUpdater.promises.refreshFeatures(
  182. userId,
  183. 'remove-user-from-groups'
  184. )
  185. for (const subscriptionId of subscriptionIds) {
  186. await _sendSubscriptionEvent(
  187. userId,
  188. subscriptionId,
  189. 'group-subscription-left'
  190. )
  191. }
  192. await _sendUserGroupPlanCodeUserProperty(userId)
  193. }
  194. async function deleteWithV1Id(v1TeamId) {
  195. await Subscription.deleteOne({ 'overleaf.id': v1TeamId }).exec()
  196. }
  197. async function deleteSubscription(subscription, deleterData) {
  198. // 1. create deletedSubscription
  199. await createDeletedSubscription(subscription, deleterData)
  200. // 2. notify analytics that members left the subscription
  201. await _sendSubscriptionEventForAllMembers(
  202. subscription._id,
  203. 'group-subscription-left'
  204. )
  205. // 3. remove subscription
  206. await Subscription.deleteOne({ _id: subscription._id }).exec()
  207. // 4. refresh users features
  208. await scheduleRefreshFeatures(subscription)
  209. }
  210. async function restoreSubscription(subscriptionId) {
  211. const deletedSubscription =
  212. await SubscriptionLocator.promises.getDeletedSubscription(subscriptionId)
  213. const subscription = deletedSubscription.subscription
  214. // 1. upsert subscription
  215. await db.subscriptions.updateOne(
  216. { _id: subscription._id },
  217. { $set: subscription },
  218. { upsert: true }
  219. )
  220. // 2. refresh users features. Do this before removing the
  221. // subscription so the restore can be retried if this fails
  222. await refreshUsersFeatures(subscription)
  223. // 3. remove deleted subscription
  224. await DeletedSubscription.deleteOne({
  225. 'subscription._id': subscription._id,
  226. }).exec()
  227. // 4. notify analytics that members rejoined the subscription
  228. await _sendSubscriptionEventForAllMembers(
  229. subscriptionId,
  230. 'group-subscription-joined'
  231. )
  232. }
  233. async function refreshUsersFeatures(subscription) {
  234. const userIds = [subscription.admin_id].concat(subscription.member_ids || [])
  235. for (const userId of userIds) {
  236. await FeaturesUpdater.promises.refreshFeatures(
  237. userId,
  238. 'subscription-updater'
  239. )
  240. }
  241. }
  242. /**
  243. *
  244. * @param {Subscription} subscription
  245. */
  246. async function scheduleRefreshFeatures(subscription) {
  247. const userIds = [subscription.admin_id].concat(subscription.member_ids || [])
  248. for (const userId of userIds) {
  249. await FeaturesUpdater.promises.scheduleRefreshFeatures(
  250. userId,
  251. 'subscription-updater'
  252. )
  253. }
  254. }
  255. async function createDeletedSubscription(subscription, deleterData) {
  256. subscription.teamInvites = []
  257. subscription.invited_emails = []
  258. const filter = { 'subscription._id': subscription._id }
  259. const data = {
  260. deleterData: {
  261. deleterId: deleterData.id,
  262. deleterIpAddress: deleterData.ip,
  263. },
  264. subscription,
  265. }
  266. const options = { upsert: true, new: true, setDefaultsOnInsert: true }
  267. await DeletedSubscription.findOneAndUpdate(filter, data, options).exec()
  268. }
  269. /**
  270. * Creates a new subscription for the given admin user.
  271. *
  272. * @param {string} adminUserId
  273. * @returns {Promise<Subscription>}
  274. */
  275. async function createNewSubscription(adminUserId) {
  276. const subscription = new Subscription({
  277. admin_id: adminUserId,
  278. manager_ids: [adminUserId],
  279. })
  280. await subscription.save()
  281. return subscription
  282. }
  283. async function _deleteAndReplaceSubscriptionFromRecurly(
  284. recurlySubscription,
  285. subscription,
  286. requesterData
  287. ) {
  288. const adminUserId = subscription.admin_id
  289. await deleteSubscription(subscription, requesterData)
  290. const newSubscription = await createNewSubscription(adminUserId)
  291. await updateSubscriptionFromRecurly(
  292. recurlySubscription,
  293. newSubscription,
  294. requesterData
  295. )
  296. }
  297. async function updateSubscriptionFromRecurly(
  298. recurlySubscription,
  299. subscription,
  300. requesterData
  301. ) {
  302. if (recurlySubscription.state === 'expired') {
  303. await handleExpiredSubscription(subscription, requesterData)
  304. return
  305. }
  306. const updatedPlanCode = recurlySubscription.plan.plan_code
  307. const plan = PlansLocator.findLocalPlanInSettings(updatedPlanCode)
  308. if (plan == null) {
  309. throw new Error(`plan code not found: ${updatedPlanCode}`)
  310. }
  311. if (!plan.groupPlan && subscription.groupPlan) {
  312. // If downgrading from group to individual plan, delete group sub and create a new one
  313. await _deleteAndReplaceSubscriptionFromRecurly(
  314. recurlySubscription,
  315. subscription,
  316. requesterData
  317. )
  318. return
  319. }
  320. const addOns = recurlySubscription?.subscription_add_ons?.map(addOn => {
  321. return {
  322. addOnCode: addOn.add_on_code,
  323. quantity: addOn.quantity,
  324. unitAmountInCents: addOn.unit_amount_in_cents,
  325. }
  326. })
  327. subscription.recurlySubscription_id = recurlySubscription.uuid
  328. subscription.planCode = updatedPlanCode
  329. subscription.addOns = addOns || []
  330. subscription.recurlyStatus = {
  331. state: recurlySubscription.state,
  332. trialStartedAt: recurlySubscription.trial_started_at,
  333. trialEndsAt: recurlySubscription.trial_ends_at,
  334. }
  335. if (plan.groupPlan) {
  336. if (!subscription.groupPlan) {
  337. subscription.member_ids = subscription.member_ids || []
  338. subscription.member_ids.push(subscription.admin_id)
  339. }
  340. subscription.groupPlan = true
  341. subscription.membersLimit = plan.membersLimit
  342. // Some plans allow adding more seats than the base plan provides.
  343. // This is recorded as a subscription add on.
  344. if (
  345. plan.membersLimitAddOn &&
  346. Array.isArray(recurlySubscription.subscription_add_ons)
  347. ) {
  348. recurlySubscription.subscription_add_ons.forEach(addOn => {
  349. if (addOn.add_on_code === plan.membersLimitAddOn) {
  350. subscription.membersLimit += addOn.quantity
  351. }
  352. })
  353. }
  354. }
  355. await subscription.save()
  356. const accountMapping =
  357. AccountMappingHelper.generateSubscriptionToRecurlyMapping(
  358. subscription._id,
  359. subscription.recurlySubscription_id
  360. )
  361. if (accountMapping) {
  362. AnalyticsManager.registerAccountMapping(accountMapping)
  363. }
  364. await scheduleRefreshFeatures(subscription)
  365. }
  366. async function _sendUserGroupPlanCodeUserProperty(userId) {
  367. try {
  368. const subscriptions =
  369. await SubscriptionLocator.promises.getMemberSubscriptions(userId)
  370. let bestPlanCode = null
  371. let bestFeatures = {}
  372. for (const subscription of subscriptions) {
  373. const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
  374. if (
  375. plan &&
  376. FeaturesHelper.isFeatureSetBetter(plan.features, bestFeatures)
  377. ) {
  378. bestPlanCode = plan.planCode
  379. bestFeatures = plan.features
  380. }
  381. }
  382. AnalyticsManager.setUserPropertyForUserInBackground(
  383. userId,
  384. 'group-subscription-plan-code',
  385. bestPlanCode
  386. )
  387. } catch (error) {
  388. logger.error(
  389. { err: error },
  390. `Failed to update group-subscription-plan-code property for user ${userId}`
  391. )
  392. }
  393. }
  394. async function handleExpiredSubscription(subscription, requesterData) {
  395. const hasManagedUsersFeature =
  396. Features.hasFeature('saas') && subscription?.managedUsersEnabled
  397. // If a payment lapses and if the group is managed or has group SSO, as a temporary measure we need to
  398. // make sure that the group continues as-is and no destructive actions are taken.
  399. if (hasManagedUsersFeature) {
  400. logger.warn(
  401. { subscriptionId: subscription._id },
  402. 'expired subscription has managedUsers feature enabled, skipping deletion'
  403. )
  404. } else {
  405. let hasGroupSSOEnabled = false
  406. if (subscription?.ssoConfig) {
  407. const ssoConfig = await SSOConfig.findOne({
  408. _id: subscription.ssoConfig._id || subscription.ssoConfig,
  409. })
  410. .lean()
  411. .exec()
  412. if (ssoConfig.enabled) {
  413. hasGroupSSOEnabled = true
  414. }
  415. }
  416. if (hasGroupSSOEnabled) {
  417. logger.warn(
  418. { subscriptionId: subscription._id },
  419. 'expired subscription has groupSSO feature enabled, skipping deletion'
  420. )
  421. } else {
  422. await deleteSubscription(subscription, requesterData)
  423. }
  424. }
  425. }
  426. async function _sendSubscriptionEvent(userId, subscriptionId, event) {
  427. const subscription = await Subscription.findOne(
  428. { _id: subscriptionId },
  429. { recurlySubscription_id: 1, groupPlan: 1 }
  430. )
  431. if (!subscription || !subscription.groupPlan) {
  432. return
  433. }
  434. AnalyticsManager.recordEventForUserInBackground(userId, event, {
  435. groupId: subscription._id.toString(),
  436. subscriptionId: subscription.recurlySubscription_id,
  437. })
  438. }
  439. async function _sendSubscriptionEventForAllMembers(subscriptionId, event) {
  440. const subscription = await Subscription.findOne(
  441. { _id: subscriptionId },
  442. {
  443. recurlySubscription_id: 1,
  444. member_ids: 1,
  445. groupPlan: 1,
  446. }
  447. )
  448. if (!subscription) {
  449. return
  450. }
  451. const userIds = (subscription.member_ids || []).filter(Boolean)
  452. for (const userId of userIds) {
  453. if (userId) {
  454. AnalyticsManager.recordEventForUserInBackground(userId, event, {
  455. groupId: subscription._id.toString(),
  456. subscriptionId: subscription.recurlySubscription_id,
  457. })
  458. }
  459. }
  460. }
  461. /**
  462. * Sets the plan code and addon state to revert the plan to in case of failed upgrades, or clears the last restore point if it was used/ voided
  463. * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for
  464. * @param {string} planCode the plan code to revert to
  465. * @param {Array<AddOn>} addOns the addOns to revert to
  466. * @param {Boolean} consumed whether the restore point was used to revert a subscription
  467. */
  468. async function setRestorePoint(subscriptionId, planCode, addOns, consumed) {
  469. const update = {
  470. $set: {
  471. 'lastSuccesfulSubscription.planCode': planCode,
  472. 'lastSuccesfulSubscription.addOns': addOns,
  473. },
  474. }
  475. if (consumed) {
  476. update.$inc = { timesRevertedDueToFailedPayment: 1 }
  477. }
  478. await Subscription.updateOne({ _id: subscriptionId }, update).exec()
  479. }
  480. /**
  481. * Clears the restore point for a given subscription, and signals that the subscription was sucessfully reverted.
  482. *
  483. * @async
  484. * @function setSubscriptionWasReverted
  485. * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for
  486. * @returns {Promise<void>} Resolves when the restore point has been cleared.
  487. */
  488. async function setSubscriptionWasReverted(subscriptionId) {
  489. // consume the backup and flag that the subscription was reverted due to failed payment
  490. await setRestorePoint(subscriptionId, null, null, true)
  491. }
  492. /**
  493. * Clears the restore point for a given subscription, and signals that the subscription was not reverted.
  494. *
  495. * @async
  496. * @function voidRestorePoint
  497. * @param {string} subscriptionId - The unique identifier of the subscription.
  498. * @returns {Promise<void>} Resolves when the restore point has been cleared.
  499. */
  500. async function voidRestorePoint(subscriptionId) {
  501. await setRestorePoint(subscriptionId, null, null, false)
  502. }
  503. export default {
  504. updateAdmin: callbackify(updateAdmin),
  505. syncSubscription: callbackify(syncSubscription),
  506. createNewSubscription: callbackify(createNewSubscription),
  507. deleteSubscription: callbackify(deleteSubscription),
  508. createDeletedSubscription: callbackify(createDeletedSubscription),
  509. addUserToGroup: callbackify(addUserToGroup),
  510. refreshUsersFeatures: callbackify(refreshUsersFeatures),
  511. removeUserFromGroup: callbackify(removeUserFromGroup),
  512. removeUserFromAllGroups: callbackify(removeUserFromAllGroups),
  513. deleteWithV1Id: callbackify(deleteWithV1Id),
  514. restoreSubscription: callbackify(restoreSubscription),
  515. updateSubscriptionFromRecurly: callbackify(updateSubscriptionFromRecurly),
  516. scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures),
  517. setSubscriptionRestorePoint: callbackify(setRestorePoint),
  518. setSubscriptionWasReverted: callbackify(setSubscriptionWasReverted),
  519. voidRestorePoint: callbackify(voidRestorePoint),
  520. promises: {
  521. updateAdmin,
  522. syncSubscription,
  523. createNewSubscription,
  524. addUserToGroup,
  525. refreshUsersFeatures,
  526. removeUserFromGroup,
  527. removeUserFromAllGroups,
  528. deleteSubscription,
  529. createDeletedSubscription,
  530. deleteWithV1Id,
  531. restoreSubscription,
  532. updateSubscriptionFromRecurly,
  533. scheduleRefreshFeatures,
  534. setRestorePoint,
  535. setSubscriptionWasReverted,
  536. voidRestorePoint,
  537. handleExpiredSubscription,
  538. },
  539. }