| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592 |
- import { db, ObjectId } from '../../infrastructure/mongodb.js'
- import { callbackify } from '@overleaf/promise-utils'
- import { Subscription } from '../../models/Subscription.js'
- import SubscriptionLocator from './SubscriptionLocator.mjs'
- import PlansLocator from './PlansLocator.mjs'
- import FeaturesUpdater from './FeaturesUpdater.mjs'
- import FeaturesHelper from './FeaturesHelper.mjs'
- import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
- import { DeletedSubscription } from '../../models/DeletedSubscription.js'
- import logger from '@overleaf/logger'
- import Features from '../../infrastructure/Features.js'
- import UserAuditLogHandler from '../User/UserAuditLogHandler.mjs'
- import UserUpdater from '../User/UserUpdater.mjs'
- import AccountMappingHelper from '../Analytics/AccountMappingHelper.mjs'
- import { SSOConfig } from '../../models/SSOConfig.js'
- import mongoose from '../../infrastructure/Mongoose.js'
- import Modules from '../../infrastructure/Modules.js'
- /**
- * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription
- * @typedef {import('../../../../types/subscription/dashboard/subscription').PaymentProvider} PaymentProvider
- * @typedef {import('../../../../types/group-management/group-audit-log').GroupAuditLog} GroupAuditLog
- * @import { AddOn } from '../../../../types/subscription/plan'
- */
- /**
- *
- * @param {GroupAuditLog} auditLog
- */
- async function subscriptionUpdateWithAuditLog(dbFilter, dbUpdate, auditLog) {
- const session = await mongoose.startSession()
- try {
- await session.withTransaction(async () => {
- await Subscription.updateOne(dbFilter, dbUpdate, { session }).exec()
- await Modules.promises.hooks.fire(
- 'addGroupAuditLogEntry',
- auditLog,
- session
- )
- })
- } finally {
- await session.endSession()
- }
- }
- /**
- * Change the admin of the given subscription.
- *
- * If the subscription is a group, add the new admin as manager while keeping
- * the old admin. Otherwise, replace the manager.
- *
- * Validation checks are assumed to have been made:
- * * subscription exists
- * * user exists
- * * user does not have another subscription
- * * subscription is not a Recurly subscription
- *
- * If the subscription is Recurly, we silently do nothing.
- */
- async function updateAdmin(subscription, adminId) {
- const query = {
- _id: new ObjectId(subscription._id),
- customAccount: true,
- }
- const update = {
- $set: { admin_id: new ObjectId(adminId) },
- }
- if (subscription.groupPlan) {
- update.$addToSet = { manager_ids: new ObjectId(adminId) }
- } else {
- update.$set.manager_ids = [new ObjectId(adminId)]
- }
- await Subscription.updateOne(query, update).exec()
- }
- async function syncSubscription(
- recurlySubscription,
- adminUserId,
- requesterData = {}
- ) {
- let subscription =
- await SubscriptionLocator.promises.getUsersSubscription(adminUserId)
- if (subscription == null) {
- subscription = await createNewSubscription(adminUserId)
- }
- await updateSubscriptionFromRecurly(
- recurlySubscription,
- subscription,
- requesterData
- )
- }
- async function addUserToGroup(subscriptionId, userId, auditLog) {
- await UserAuditLogHandler.promises.addEntry(
- userId,
- 'join-group-subscription',
- undefined,
- undefined,
- { subscriptionId }
- )
- await subscriptionUpdateWithAuditLog(
- { _id: subscriptionId },
- { $addToSet: { member_ids: userId } },
- {
- initiatorId: auditLog?.initiatorId,
- ipAddress: auditLog?.ipAddress,
- groupId: subscriptionId,
- operation: 'join-group',
- }
- )
- await FeaturesUpdater.promises.refreshFeatures(userId, 'add-to-group')
- await _sendUserGroupPlanCodeUserProperty(userId)
- await _sendSubscriptionEvent(
- userId,
- subscriptionId,
- 'group-subscription-joined'
- )
- }
- async function removeUserFromGroup(subscriptionId, userId, auditLog) {
- await UserAuditLogHandler.promises.addEntry(
- userId,
- 'leave-group-subscription',
- undefined,
- undefined,
- { subscriptionId }
- )
- await subscriptionUpdateWithAuditLog(
- { _id: subscriptionId },
- { $pull: { member_ids: userId } },
- {
- initiatorId: auditLog?.initiatorId,
- ipAddress: auditLog?.ipAddress,
- groupId: subscriptionId,
- operation: 'leave-group',
- info: { userIdRemoved: userId },
- }
- )
- await Subscription.updateOne(
- { _id: subscriptionId },
- { $pull: { member_ids: userId } }
- ).exec()
- const subscription = await Subscription.findById(subscriptionId)
- if (subscription.managedUsersEnabled) {
- await UserUpdater.promises.updateUser(
- { _id: userId },
- {
- $unset: {
- 'enrollment.managedBy': 1,
- 'enrollment.enrolledAt': 1,
- },
- }
- )
- }
- await FeaturesUpdater.promises.refreshFeatures(
- userId,
- 'remove-user-from-group'
- )
- await _sendUserGroupPlanCodeUserProperty(userId)
- await _sendSubscriptionEvent(
- userId,
- subscriptionId,
- 'group-subscription-left'
- )
- }
- async function removeUserFromAllGroups(userId) {
- const subscriptions =
- await SubscriptionLocator.promises.getMemberSubscriptions(userId)
- if (subscriptions.length === 0) {
- return
- }
- const subscriptionIds = subscriptions.map(sub => sub._id)
- const removeOperation = { $pull: { member_ids: userId } }
- for (const subscriptionId of subscriptionIds) {
- await UserAuditLogHandler.promises.addEntry(
- userId,
- 'leave-group-subscription',
- undefined,
- undefined,
- { subscriptionId }
- )
- }
- await Subscription.updateMany(
- { _id: subscriptionIds },
- removeOperation
- ).exec()
- await FeaturesUpdater.promises.refreshFeatures(
- userId,
- 'remove-user-from-groups'
- )
- for (const subscriptionId of subscriptionIds) {
- await _sendSubscriptionEvent(
- userId,
- subscriptionId,
- 'group-subscription-left'
- )
- }
- await _sendUserGroupPlanCodeUserProperty(userId)
- }
- async function deleteWithV1Id(v1TeamId) {
- await Subscription.deleteOne({ 'overleaf.id': v1TeamId }).exec()
- }
- async function deleteSubscription(subscription, deleterData) {
- // 1. create deletedSubscription
- await createDeletedSubscription(subscription, deleterData)
- // 2. notify analytics that members left the subscription
- await _sendSubscriptionEventForAllMembers(
- subscription._id,
- 'group-subscription-left'
- )
- // 3. remove subscription
- await Subscription.deleteOne({ _id: subscription._id }).exec()
- // 4. refresh users features
- await scheduleRefreshFeatures(subscription)
- }
- async function restoreSubscription(subscriptionId) {
- const deletedSubscription =
- await SubscriptionLocator.promises.getDeletedSubscription(subscriptionId)
- const subscription = deletedSubscription.subscription
- // 1. upsert subscription
- await db.subscriptions.updateOne(
- { _id: subscription._id },
- { $set: subscription },
- { upsert: true }
- )
- // 2. refresh users features. Do this before removing the
- // subscription so the restore can be retried if this fails
- await refreshUsersFeatures(subscription)
- // 3. remove deleted subscription
- await DeletedSubscription.deleteOne({
- 'subscription._id': subscription._id,
- }).exec()
- // 4. notify analytics that members rejoined the subscription
- await _sendSubscriptionEventForAllMembers(
- subscriptionId,
- 'group-subscription-joined'
- )
- }
- async function refreshUsersFeatures(subscription) {
- const userIds = [subscription.admin_id].concat(subscription.member_ids || [])
- for (const userId of userIds) {
- await FeaturesUpdater.promises.refreshFeatures(
- userId,
- 'subscription-updater'
- )
- }
- }
- /**
- *
- * @param {Subscription} subscription
- */
- async function scheduleRefreshFeatures(subscription) {
- const userIds = [subscription.admin_id].concat(subscription.member_ids || [])
- for (const userId of userIds) {
- await FeaturesUpdater.promises.scheduleRefreshFeatures(
- userId,
- 'subscription-updater'
- )
- }
- }
- async function createDeletedSubscription(subscription, deleterData) {
- subscription.teamInvites = []
- subscription.invited_emails = []
- const filter = { 'subscription._id': subscription._id }
- const data = {
- deleterData: {
- deleterId: deleterData.id,
- deleterIpAddress: deleterData.ip,
- },
- subscription,
- }
- const options = { upsert: true, new: true, setDefaultsOnInsert: true }
- await DeletedSubscription.findOneAndUpdate(filter, data, options).exec()
- }
- /**
- * Creates a new subscription for the given admin user.
- *
- * @param {string} adminUserId
- * @returns {Promise<Subscription>}
- */
- async function createNewSubscription(adminUserId) {
- const subscription = new Subscription({
- admin_id: adminUserId,
- manager_ids: [adminUserId],
- })
- await subscription.save()
- return subscription
- }
- async function _deleteAndReplaceSubscriptionFromRecurly(
- recurlySubscription,
- subscription,
- requesterData
- ) {
- const adminUserId = subscription.admin_id
- await deleteSubscription(subscription, requesterData)
- const newSubscription = await createNewSubscription(adminUserId)
- await updateSubscriptionFromRecurly(
- recurlySubscription,
- newSubscription,
- requesterData
- )
- }
- async function updateSubscriptionFromRecurly(
- recurlySubscription,
- subscription,
- requesterData
- ) {
- if (recurlySubscription.state === 'expired') {
- await handleExpiredSubscription(subscription, requesterData)
- return
- }
- const updatedPlanCode = recurlySubscription.plan.plan_code
- const plan = PlansLocator.findLocalPlanInSettings(updatedPlanCode)
- if (plan == null) {
- throw new Error(`plan code not found: ${updatedPlanCode}`)
- }
- if (!plan.groupPlan && subscription.groupPlan) {
- // If downgrading from group to individual plan, delete group sub and create a new one
- await _deleteAndReplaceSubscriptionFromRecurly(
- recurlySubscription,
- subscription,
- requesterData
- )
- return
- }
- const addOns = recurlySubscription?.subscription_add_ons?.map(addOn => {
- return {
- addOnCode: addOn.add_on_code,
- quantity: addOn.quantity,
- unitAmountInCents: addOn.unit_amount_in_cents,
- }
- })
- subscription.recurlySubscription_id = recurlySubscription.uuid
- subscription.planCode = updatedPlanCode
- subscription.addOns = addOns || []
- subscription.recurlyStatus = {
- state: recurlySubscription.state,
- trialStartedAt: recurlySubscription.trial_started_at,
- trialEndsAt: recurlySubscription.trial_ends_at,
- }
- if (plan.groupPlan) {
- if (!subscription.groupPlan) {
- subscription.member_ids = subscription.member_ids || []
- subscription.member_ids.push(subscription.admin_id)
- }
- subscription.groupPlan = true
- subscription.membersLimit = plan.membersLimit
- // Some plans allow adding more seats than the base plan provides.
- // This is recorded as a subscription add on.
- if (
- plan.membersLimitAddOn &&
- Array.isArray(recurlySubscription.subscription_add_ons)
- ) {
- recurlySubscription.subscription_add_ons.forEach(addOn => {
- if (addOn.add_on_code === plan.membersLimitAddOn) {
- subscription.membersLimit += addOn.quantity
- }
- })
- }
- }
- await subscription.save()
- const accountMapping =
- AccountMappingHelper.generateSubscriptionToRecurlyMapping(
- subscription._id,
- subscription.recurlySubscription_id
- )
- if (accountMapping) {
- AnalyticsManager.registerAccountMapping(accountMapping)
- }
- await scheduleRefreshFeatures(subscription)
- }
- async function _sendUserGroupPlanCodeUserProperty(userId) {
- try {
- const subscriptions =
- await SubscriptionLocator.promises.getMemberSubscriptions(userId)
- let bestPlanCode = null
- let bestFeatures = {}
- for (const subscription of subscriptions) {
- const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
- if (
- plan &&
- FeaturesHelper.isFeatureSetBetter(plan.features, bestFeatures)
- ) {
- bestPlanCode = plan.planCode
- bestFeatures = plan.features
- }
- }
- AnalyticsManager.setUserPropertyForUserInBackground(
- userId,
- 'group-subscription-plan-code',
- bestPlanCode
- )
- } catch (error) {
- logger.error(
- { err: error },
- `Failed to update group-subscription-plan-code property for user ${userId}`
- )
- }
- }
- async function handleExpiredSubscription(subscription, requesterData) {
- const hasManagedUsersFeature =
- Features.hasFeature('saas') && subscription?.managedUsersEnabled
- // If a payment lapses and if the group is managed or has group SSO, as a temporary measure we need to
- // make sure that the group continues as-is and no destructive actions are taken.
- if (hasManagedUsersFeature) {
- logger.warn(
- { subscriptionId: subscription._id },
- 'expired subscription has managedUsers feature enabled, skipping deletion'
- )
- } else {
- let hasGroupSSOEnabled = false
- if (subscription?.ssoConfig) {
- const ssoConfig = await SSOConfig.findOne({
- _id: subscription.ssoConfig._id || subscription.ssoConfig,
- })
- .lean()
- .exec()
- if (ssoConfig.enabled) {
- hasGroupSSOEnabled = true
- }
- }
- if (hasGroupSSOEnabled) {
- logger.warn(
- { subscriptionId: subscription._id },
- 'expired subscription has groupSSO feature enabled, skipping deletion'
- )
- } else {
- await deleteSubscription(subscription, requesterData)
- }
- }
- }
- async function _sendSubscriptionEvent(userId, subscriptionId, event) {
- const subscription = await Subscription.findOne(
- { _id: subscriptionId },
- { recurlySubscription_id: 1, groupPlan: 1 }
- )
- if (!subscription || !subscription.groupPlan) {
- return
- }
- AnalyticsManager.recordEventForUserInBackground(userId, event, {
- groupId: subscription._id.toString(),
- subscriptionId: subscription.recurlySubscription_id,
- })
- }
- async function _sendSubscriptionEventForAllMembers(subscriptionId, event) {
- const subscription = await Subscription.findOne(
- { _id: subscriptionId },
- {
- recurlySubscription_id: 1,
- member_ids: 1,
- groupPlan: 1,
- }
- )
- if (!subscription) {
- return
- }
- const userIds = (subscription.member_ids || []).filter(Boolean)
- for (const userId of userIds) {
- if (userId) {
- AnalyticsManager.recordEventForUserInBackground(userId, event, {
- groupId: subscription._id.toString(),
- subscriptionId: subscription.recurlySubscription_id,
- })
- }
- }
- }
- /**
- * 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
- * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for
- * @param {string} planCode the plan code to revert to
- * @param {Array<AddOn>} addOns the addOns to revert to
- * @param {Boolean} consumed whether the restore point was used to revert a subscription
- */
- async function setRestorePoint(subscriptionId, planCode, addOns, consumed) {
- const update = {
- $set: {
- 'lastSuccesfulSubscription.planCode': planCode,
- 'lastSuccesfulSubscription.addOns': addOns,
- },
- }
- if (consumed) {
- update.$inc = { timesRevertedDueToFailedPayment: 1 }
- }
- await Subscription.updateOne({ _id: subscriptionId }, update).exec()
- }
- /**
- * Clears the restore point for a given subscription, and signals that the subscription was sucessfully reverted.
- *
- * @async
- * @function setSubscriptionWasReverted
- * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for
- * @returns {Promise<void>} Resolves when the restore point has been cleared.
- */
- async function setSubscriptionWasReverted(subscriptionId) {
- // consume the backup and flag that the subscription was reverted due to failed payment
- await setRestorePoint(subscriptionId, null, null, true)
- }
- /**
- * Clears the restore point for a given subscription, and signals that the subscription was not reverted.
- *
- * @async
- * @function voidRestorePoint
- * @param {string} subscriptionId - The unique identifier of the subscription.
- * @returns {Promise<void>} Resolves when the restore point has been cleared.
- */
- async function voidRestorePoint(subscriptionId) {
- await setRestorePoint(subscriptionId, null, null, false)
- }
- export default {
- updateAdmin: callbackify(updateAdmin),
- syncSubscription: callbackify(syncSubscription),
- createNewSubscription: callbackify(createNewSubscription),
- deleteSubscription: callbackify(deleteSubscription),
- createDeletedSubscription: callbackify(createDeletedSubscription),
- addUserToGroup: callbackify(addUserToGroup),
- refreshUsersFeatures: callbackify(refreshUsersFeatures),
- removeUserFromGroup: callbackify(removeUserFromGroup),
- removeUserFromAllGroups: callbackify(removeUserFromAllGroups),
- deleteWithV1Id: callbackify(deleteWithV1Id),
- restoreSubscription: callbackify(restoreSubscription),
- updateSubscriptionFromRecurly: callbackify(updateSubscriptionFromRecurly),
- scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures),
- setSubscriptionRestorePoint: callbackify(setRestorePoint),
- setSubscriptionWasReverted: callbackify(setSubscriptionWasReverted),
- voidRestorePoint: callbackify(voidRestorePoint),
- promises: {
- updateAdmin,
- syncSubscription,
- createNewSubscription,
- addUserToGroup,
- refreshUsersFeatures,
- removeUserFromGroup,
- removeUserFromAllGroups,
- deleteSubscription,
- createDeletedSubscription,
- deleteWithV1Id,
- restoreSubscription,
- updateSubscriptionFromRecurly,
- scheduleRefreshFeatures,
- setRestorePoint,
- setSubscriptionWasReverted,
- voidRestorePoint,
- handleExpiredSubscription,
- },
- }
|