mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-27 02:51:57 +02:00
* 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
573 lines
17 KiB
JavaScript
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,
|
|
},
|
|
}
|