const { callbackify } = require('util') const SubscriptionUpdater = require('./SubscriptionUpdater') const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionController = require('./SubscriptionController') const { Subscription } = require('../../models/Subscription') const RecurlyClient = require('./RecurlyClient') const PlansLocator = require('./PlansLocator') const SubscriptionHandler = require('./SubscriptionHandler') const GroupPlansData = require('./GroupPlansData') const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./RecurlyEntities') const { ManuallyCollectedError, PendingChangeError } = require('./Errors') async function removeUserFromGroup(subscriptionId, userIdToRemove) { await SubscriptionUpdater.promises.removeUserFromGroup( subscriptionId, userIdToRemove ) } 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 (subscription?.recurlyStatus?.state !== 'active') { throw new Error('The subscription is not active') } } async function ensureSubscriptionCollectionMethodIsNotManual( recurlySubscription ) { if (recurlySubscription.isCollectionMethodManual) { throw new ManuallyCollectedError( 'This subscription is being collected manually', { recurlySubscription_id: recurlySubscription.id, } ) } } async function ensureSubscriptionHasNoPendingChanges(recurlySubscription) { if (recurlySubscription.pendingChange) { throw new PendingChangeError('This subscription has a pending change', { recurlySubscription_id: recurlySubscription.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 recurlySubscription = await RecurlyClient.promises.getSubscription( subscription.recurlySubscription_id ) return { userId, subscription, recurlySubscription, plan, } } async function _addSeatsSubscriptionChange(userId, adding) { const { subscription, recurlySubscription, plan } = await getUsersGroupSubscriptionDetails(userId) await ensureFlexibleLicensingEnabled(plan) await ensureSubscriptionIsActive(subscription) await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription) await ensureSubscriptionHasNoPendingChanges(recurlySubscription) const currentAddonQuantity = recurlySubscription.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 (recurlySubscription.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 = recurlySubscription.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 = recurlySubscription.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( recurlySubscription.planPrice, planPriceInCents / 100, usage, size ) ) { unitPrice = legacyUnitPriceInCents / 100 } changeRequest = recurlySubscription.getRequestForAddOnPurchase( MEMBERS_LIMIT_ADD_ON_CODE, nextAddonQuantity, unitPrice ) } return { changeRequest, currentAddonQuantity, recurlySubscription, } } 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 paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId) const subscriptionChange = await RecurlyClient.promises.previewSubscriptionChange(changeRequest) const subscriptionChangePreview = await 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, paymentMethod ) return subscriptionChangePreview } async function createAddSeatsSubscriptionChange(userId, adding) { const { changeRequest, recurlySubscription } = await _addSeatsSubscriptionChange(userId, adding) await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) await SubscriptionHandler.promises.syncSubscription( { uuid: recurlySubscription.id }, userId ) return { adding } } async function _getUpgradeTargetPlanCodeMaybeThrow(subscription) { if ( subscription.planCode.includes('professional') || !subscription.groupPlan ) { throw new Error('Not eligible for group plan upgrade') } return subscription.planCode.replace('collaborator', 'professional') } async function _getGroupPlanUpgradeChangeRequest(ownerId) { const olSubscription = await SubscriptionLocator.promises.getUsersSubscription(ownerId) await ensureSubscriptionIsActive(olSubscription) const newPlanCode = await _getUpgradeTargetPlanCodeMaybeThrow(olSubscription) const recurlySubscription = await RecurlyClient.promises.getSubscription( olSubscription.recurlySubscription_id ) await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription) await ensureSubscriptionHasNoPendingChanges(recurlySubscription) return recurlySubscription.getRequestForGroupPlanUpgrade(newPlanCode) } async function getGroupPlanUpgradePreview(ownerId) { const changeRequest = await _getGroupPlanUpgradeChangeRequest(ownerId) const subscriptionChange = await RecurlyClient.promises.previewSubscriptionChange(changeRequest) const paymentMethod = await RecurlyClient.promises.getPaymentMethod(ownerId) return SubscriptionController.makeChangePreview( { type: 'group-plan-upgrade', prevPlan: { name: SubscriptionController.getPlanNameForDisplay( subscriptionChange.subscription.planName, subscriptionChange.subscription.planCode ), }, }, subscriptionChange, paymentMethod ) } async function upgradeGroupPlan(ownerId) { const changeRequest = await _getGroupPlanUpgradeChangeRequest(ownerId) await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) await SubscriptionHandler.promises.syncSubscription( { uuid: changeRequest.subscription.id }, ownerId ) } module.exports = { removeUserFromGroup: callbackify(removeUserFromGroup), replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups), ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled), ensureSubscriptionIsActive: callbackify(ensureSubscriptionIsActive), ensureSubscriptionCollectionMethodIsNotManual: callbackify( ensureSubscriptionCollectionMethodIsNotManual ), ensureSubscriptionHasNoPendingChanges: callbackify( ensureSubscriptionHasNoPendingChanges ), getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup), isUserPartOfGroup: callbackify(isUserPartOfGroup), getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview), upgradeGroupPlan: callbackify(upgradeGroupPlan), promises: { removeUserFromGroup, replaceUserReferencesInGroups, ensureFlexibleLicensingEnabled, ensureSubscriptionIsActive, ensureSubscriptionCollectionMethodIsNotManual, ensureSubscriptionHasNoPendingChanges, getTotalConfirmedUsersInGroup, isUserPartOfGroup, getUsersGroupSubscriptionDetails, previewAddSeatsSubscriptionChange, createAddSeatsSubscriptionChange, getGroupPlanUpgradePreview, upgradeGroupPlan, }, }