const { callbackify } = require('util') const _ = require('lodash') const OError = require('@overleaf/o-error') const SubscriptionUpdater = require('./SubscriptionUpdater') const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionController = require('./SubscriptionController') const SubscriptionHelper = require('./SubscriptionHelper') const { Subscription } = require('../../models/Subscription') const { User } = require('../../models/User') const PlansLocator = require('./PlansLocator') const TeamInvitesHandler = require('./TeamInvitesHandler') const GroupPlansData = require('./GroupPlansData') const Modules = require('../../infrastructure/Modules') const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities') const { ManuallyCollectedError, PendingChangeError, InactiveError, HasPastDueInvoiceError, HasNoAdditionalLicenseWhenManuallyCollectedError, } = require('./Errors') const EmailHelper = require('../Helpers/EmailHelper') const { InvalidEmailError } = require('../Errors/Errors') async function removeUserFromGroup(subscriptionId, userIdToRemove, auditLog) { await SubscriptionUpdater.promises.removeUserFromGroup( subscriptionId, userIdToRemove, auditLog ) } async function replaceUserReferencesInGroups(oldId, newId) { await Subscription.updateOne({ admin_id: oldId }, { admin_id: newId }).exec() await _replaceInArray(Subscription, 'manager_ids', oldId, newId) await _replaceInArray(Subscription, 'member_ids', oldId, newId) } async function isUserPartOfGroup(userId, subscriptionId) { const subscription = await SubscriptionLocator.promises.getSubscriptionByMemberIdAndId( userId, subscriptionId ) return !!subscription } async function getTotalConfirmedUsersInGroup(subscriptionId) { const subscription = await SubscriptionLocator.promises.getSubscription(subscriptionId) return subscription?.member_ids?.length } async function _replaceInArray(model, property, oldValue, newValue) { // Mongo won't let us pull and addToSet in the same query, so do it in // two. Note we need to add first, since the query is based on the old user. const query = {} query[property] = oldValue const setNewValue = {} setNewValue[property] = newValue const setOldValue = {} setOldValue[property] = oldValue await model.updateMany(query, { $addToSet: setNewValue }) await model.updateMany(query, { $pull: setOldValue }) } async function ensureFlexibleLicensingEnabled(plan) { if (!plan?.canUseFlexibleLicensing) { throw new Error('The group plan does not support flexible licensing') } } async function ensureSubscriptionIsActive(subscription) { if (SubscriptionHelper.getPaidSubscriptionState(subscription) !== 'active') { throw new InactiveError('The subscription is not active', { subscriptionId: subscription._id.toString(), }) } } async function ensureSubscriptionCollectionMethodIsNotManual( paymentProviderSubscription ) { if (paymentProviderSubscription.isCollectionMethodManual) { throw new ManuallyCollectedError( 'This subscription is being collected manually', { subscription_id: paymentProviderSubscription.id, } ) } } async function ensureSubscriptionHasNoPendingChanges( paymentProviderSubscription ) { if (paymentProviderSubscription.pendingChange) { throw new PendingChangeError('This subscription has a pending change', { subscription_id: paymentProviderSubscription.id, }) } } async function ensureSubscriptionHasNoPastDueInvoice(subscription) { const [paymentRecord] = await Modules.promises.hooks.fire( 'getPaymentFromRecord', subscription ) if (paymentRecord.account.hasPastDueInvoice) { throw new HasPastDueInvoiceError( 'This subscription has a past due invoice', { subscriptionId: subscription._id.toString(), } ) } } async function ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( paymentProviderSubscription ) { if ( paymentProviderSubscription.isCollectionMethodManual && !paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE) ) { throw new HasNoAdditionalLicenseWhenManuallyCollectedError( 'This subscription is being collected manually has no "additional-license" add-on', { subscription_id: paymentProviderSubscription.id, } ) } } async function getUsersGroupSubscriptionDetails(userId) { const subscription = await SubscriptionLocator.promises.getUsersSubscription(userId) if (!subscription) { throw new Error('No subscription was found') } if (!subscription.groupPlan) { throw new Error('User subscription is not a group plan') } const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode) const response = await Modules.promises.hooks.fire( 'getPaymentFromRecord', subscription ) const { subscription: paymentProviderSubscription } = response[0] return { userId, subscription, paymentProviderSubscription, plan, } } async function checkBillingInfoExistence(paymentProviderSubscription, userId) { // Verify the billing info only if the collection method is not manual (e.g. automatic) if (!paymentProviderSubscription.isCollectionMethodManual) { // Check if the user has missing billing details await Modules.promises.hooks.fire('getPaymentMethod', userId) } } async function _addSeatsSubscriptionChange(userId, adding) { const { subscription, paymentProviderSubscription, plan } = await getUsersGroupSubscriptionDetails(userId) await ensureFlexibleLicensingEnabled(plan) await ensureSubscriptionIsActive(subscription) await ensureSubscriptionHasNoPendingChanges(paymentProviderSubscription) await checkBillingInfoExistence(paymentProviderSubscription, userId) await ensureSubscriptionHasNoPastDueInvoice(subscription) const currentAddonQuantity = paymentProviderSubscription.addOns.find( addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE )?.quantity ?? 0 // Keeps only the new total quantity of addon const nextAddonQuantity = currentAddonQuantity + adding let changeRequest if (paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) { // Not providing a custom price as once the subscription is locked // to an add-on at a given price, it will use it for subsequent payments changeRequest = paymentProviderSubscription.getRequestForAddOnUpdate( MEMBERS_LIMIT_ADD_ON_CODE, nextAddonQuantity ) } else { let unitPrice const pattern = /^group_(collaborator|professional)_(2|3|4|5|10|20|50)_(educational|enterprise)$/ const [, planCode, size, usage] = plan.planCode.match(pattern) const currency = paymentProviderSubscription.currency const planPriceInCents = GroupPlansData[usage][planCode][currency][size].price_in_cents const legacyUnitPriceInCents = GroupPlansData[usage][planCode][currency][size] .additional_license_legacy_price_in_cents if ( _shouldUseLegacyPricing( paymentProviderSubscription.planPrice, planPriceInCents / 100, usage, size ) ) { unitPrice = legacyUnitPriceInCents / 100 } changeRequest = paymentProviderSubscription.getRequestForAddOnPurchase( MEMBERS_LIMIT_ADD_ON_CODE, nextAddonQuantity, unitPrice ) } return { changeRequest, currentAddonQuantity, paymentProviderSubscription, } } function _shouldUseLegacyPricing( actualPlanPrice, currentPlanPrice, usage, size ) { // For small educational groups (5 or fewer members) // 2025 pricing is cheaper than legacy pricing if (size <= 5 && usage === 'educational') { return currentPlanPrice < actualPlanPrice } // For all other scenarios // 2025 pricing is more expensive than legacy pricing return currentPlanPrice > actualPlanPrice } async function previewAddSeatsSubscriptionChange(userId, adding) { const { changeRequest, currentAddonQuantity } = await _addSeatsSubscriptionChange(userId, adding) const response = await Modules.promises.hooks.fire( 'previewSubscriptionChangeRequest', changeRequest ) const subscriptionChange = response[0] const subscriptionChangePreview = SubscriptionController.makeChangePreview( { type: 'add-on-update', addOn: { code: MEMBERS_LIMIT_ADD_ON_CODE, quantity: subscriptionChange.nextAddOns.find( addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE ).quantity, prevQuantity: currentAddonQuantity, }, }, subscriptionChange ) return subscriptionChangePreview } async function createAddSeatsSubscriptionChange(userId, adding, poNumber) { const { changeRequest, paymentProviderSubscription } = await _addSeatsSubscriptionChange(userId, adding) let subscriptionDetailUpdateRequest if (paymentProviderSubscription.isCollectionMethodManual) { subscriptionDetailUpdateRequest = await updateSubscriptionPaymentTerms( paymentProviderSubscription, poNumber ) } await Modules.promises.hooks.fire( 'applySubscriptionChangeRequestAndSync', changeRequest, userId, subscriptionDetailUpdateRequest?.termsAndConditions ) return { adding } } async function updateSubscriptionPaymentTerms( paymentProviderSubscription, poNumber ) { const [termsAndConditions] = await Modules.promises.hooks.fire( 'generateTermsAndConditions', { currency: paymentProviderSubscription.currency, poNumber } ) const subscriptionDetailUpdateRequest = poNumber ? paymentProviderSubscription.getRequestForPoNumberAndTermsAndConditionsUpdate( poNumber, termsAndConditions ) : paymentProviderSubscription.getRequestForTermsAndConditionsUpdate( termsAndConditions ) await Modules.promises.hooks.fire( 'updateSubscriptionDetails', subscriptionDetailUpdateRequest ) return subscriptionDetailUpdateRequest } async function getGroupPlanUpgradePreview(ownerId) { const preview = await Modules.promises.hooks.fire( 'previewGroupPlanUpgrade', ownerId ) const { subscriptionChange, paymentMethod } = preview[0] return SubscriptionController.makeChangePreview( { type: 'group-plan-upgrade', prevPlan: { name: SubscriptionController.getPlanNameForDisplay( subscriptionChange.subscription.planName, subscriptionChange.subscription.planCode ), }, }, subscriptionChange, paymentMethod ) } async function upgradeGroupPlan(ownerId) { await Modules.promises.hooks.fire('upgradeGroupPlan', ownerId) } async function updateGroupMembersBulk( inviterId, subscriptionId, emailList, options = {} ) { const { removeMembersNotIncluded, commit } = options // remove duplications and empty values emailList = _.uniq(_.compact(emailList)) const invalidEmails = emailList.filter( email => !EmailHelper.parseEmail(email) ) if (invalidEmails.length > 0) { throw new InvalidEmailError('email not valid', { invalidEmails, }) } const subscription = await Subscription.findOne({ _id: subscriptionId, }).exec() const existingUserData = await User.find( { _id: { $in: subscription.member_ids }, }, { _id: 1, email: 1, 'emails.email': 1 } ).exec() const existingUsers = existingUserData.map(user => ({ _id: user._id, emails: user.emails?.map(user => user.email), })) const currentMemberEmails = _.flatten( existingUsers .filter(userData => userData.emails?.length > 0) .map(user => user.emails) ) const currentInvites = subscription.teamInvites?.map(invite => invite.email) || [] if (subscription.invited_emails?.length > 0) { currentInvites.push(...subscription.invited_emails) } const invitesToSend = _.difference( emailList, currentMemberEmails.concat(currentInvites) ) let membersToRemove let invitesToRevoke let newTotalCount if (!removeMembersNotIncluded) { membersToRemove = [] invitesToRevoke = [] newTotalCount = existingUsers.length + currentInvites.length + invitesToSend.length } else { membersToRemove = [] for (const existingUser of existingUsers) { if (_.intersection(existingUser.emails, emailList).length === 0) { membersToRemove.push(existingUser._id) } } const invitesToMaintain = _.intersection(emailList, currentInvites) invitesToRevoke = _.difference(currentInvites, invitesToMaintain) newTotalCount = existingUsers.length - membersToRemove.length + invitesToMaintain.length + invitesToSend.length } const result = { emailsToSendInvite: invitesToSend, emailsToRevokeInvite: invitesToRevoke, membersToRemove, currentMemberCount: existingUsers.length, newTotalCount, membersLimit: subscription.membersLimit, } if (commit) { if (newTotalCount > subscription.membersLimit) { const { currentMemberCount, newTotalCount, membersLimit } = result throw new OError('limit reached', { currentMemberCount, newTotalCount, membersLimit, }) } for (const email of invitesToSend) { await TeamInvitesHandler.promises.createInvite( inviterId, subscription, email ) } for (const email of invitesToRevoke) { await TeamInvitesHandler.promises.revokeInvite( inviterId, subscription, email ) } for (const user of membersToRemove) { await removeUserFromGroup(subscription._id, user._id, { initiatorId: inviterId, }) } } return result } module.exports = { removeUserFromGroup: callbackify(removeUserFromGroup), replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups), ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled), ensureSubscriptionIsActive: callbackify(ensureSubscriptionIsActive), ensureSubscriptionCollectionMethodIsNotManual: callbackify( ensureSubscriptionCollectionMethodIsNotManual ), ensureSubscriptionHasNoPendingChanges: callbackify( ensureSubscriptionHasNoPendingChanges ), ensureSubscriptionHasNoPastDueInvoice: callbackify( ensureSubscriptionHasNoPastDueInvoice ), ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual: callbackify( ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual ), getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup), isUserPartOfGroup: callbackify(isUserPartOfGroup), getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview), upgradeGroupPlan: callbackify(upgradeGroupPlan), checkBillingInfoExistence: callbackify(checkBillingInfoExistence), updateGroupMembersBulk: callbackify(updateGroupMembersBulk), promises: { removeUserFromGroup, replaceUserReferencesInGroups, ensureFlexibleLicensingEnabled, ensureSubscriptionIsActive, ensureSubscriptionCollectionMethodIsNotManual, ensureSubscriptionHasNoPendingChanges, ensureSubscriptionHasNoPastDueInvoice, ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual, getTotalConfirmedUsersInGroup, isUserPartOfGroup, getUsersGroupSubscriptionDetails, previewAddSeatsSubscriptionChange, createAddSeatsSubscriptionChange, updateSubscriptionPaymentTerms, getGroupPlanUpgradePreview, upgradeGroupPlan, checkBillingInfoExistence, updateGroupMembersBulk, }, }