Files
overleaf-cep/services/web/app/src/Features/Subscription/SubscriptionUpdater.js
Jimmy Domagala-Tang d49a8f83df Revert Recurly based subscription upgrades on failed payments (#25824)
* feat: add ability to set restore point for subscriptions

* feat: update recurly client with ability to get past due invoices and fail invoices

* utility to retrieve last valid subscription

* create revert requests and fail invoices, revert subscriptions to previous valid states on failed upgrade payments

* add restore point and call to revert plans on failed payments

* code style for PaymentProviderEntities

* moving subs restore point check to SubscriptionController, and removing unecessary error

* adding ability to stop sub restores without a deploy

* ensure that subs restore point is set before changing plan

* changing reverted flag on subscription to count, and only reverting automatic invoices

* updating tests with restorepoint functions

* rethrow error after voiding restore point, and ensure that recurly failed_payment always gets a 200 response

* only void restore point if the changeRequest fails

GitOrigin-RevId: cf3074c13db22d1cf680b59c4d57817c390db23e
2025-05-29 08:06:11 +00:00

573 lines
17 KiB
JavaScript

const { db, ObjectId } = require('../../infrastructure/mongodb')
const { callbackify } = require('@overleaf/promise-utils')
const { Subscription } = require('../../models/Subscription')
const SubscriptionLocator = require('./SubscriptionLocator')
const PlansLocator = require('./PlansLocator')
const FeaturesUpdater = require('./FeaturesUpdater')
const FeaturesHelper = require('./FeaturesHelper')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const { DeletedSubscription } = require('../../models/DeletedSubscription')
const logger = require('@overleaf/logger')
const Features = require('../../infrastructure/Features')
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
const AccountMappingHelper = require('../Analytics/AccountMappingHelper')
const { SSOConfig } = require('../../models/SSOConfig')
const mongoose = require('../../infrastructure/Mongoose')
const Modules = require('../../infrastructure/Modules')
/**
* @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()
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') {
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)
}
}
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 _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 = { revertedDueToFailedPayment: 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)
}
module.exports = {
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,
},
}