Files
overleaf-cep/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
ilkin-overleaf d4d1a23a1c Merge pull request #23547 from overleaf/ii-flexible-group-licensing-error-assist-2
[web] Hide flexible licensing buttons for pending plans (fix)

GitOrigin-RevId: ce5b4ce4138ed7a029b840a87c5498227e3204f4
2025-02-13 09:04:57 +00:00

321 lines
10 KiB
JavaScript

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,
},
}