Files
overleaf-cep/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
T
Liangjun Song 898cdb00e1 Merge pull request #27150 from overleaf/ls-flexible-licensing-for-stripe-manually-billed-users
Support Stripe manually billed users in flexible licensing

GitOrigin-RevId: b3211577a313f3a241320bfe3910cf648ee49319
2025-07-25 08:05:32 +00:00

476 lines
14 KiB
JavaScript

// ts-check
import SubscriptionGroupHandler from './SubscriptionGroupHandler.js'
import OError from '@overleaf/o-error'
import logger from '@overleaf/logger'
import SubscriptionLocator from './SubscriptionLocator.js'
import SessionManager from '../Authentication/SessionManager.js'
import UserAuditLogHandler from '../User/UserAuditLogHandler.js'
import { expressify } from '@overleaf/promise-utils'
import Modules from '../../infrastructure/Modules.js'
import UserGetter from '../User/UserGetter.js'
import { Subscription } from '../../models/Subscription.js'
import { isProfessionalGroupPlan } from './PlansHelper.mjs'
import {
MissingBillingInfoError,
ManuallyCollectedError,
PendingChangeError,
InactiveError,
SubtotalLimitExceededError,
HasPastDueInvoiceError,
HasNoAdditionalLicenseWhenManuallyCollectedError,
PaymentActionRequiredError,
} from './Errors.js'
/**
* @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js"
*/
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function removeUserFromGroup(req, res) {
const subscription = req.entity
const userToRemoveId = req.params.user_id
const loggedInUserId = SessionManager.getLoggedInUserId(req.session)
const subscriptionId = subscription._id
logger.debug(
{ subscriptionId, userToRemoveId },
'removing user from group subscription'
)
await _removeUserFromGroup(req, res, {
userToRemoveId,
loggedInUserId,
subscription,
})
}
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function removeSelfFromGroup(req, res) {
const userToRemoveId = SessionManager.getLoggedInUserId(req.session)
const subscription = await SubscriptionLocator.promises.getSubscription(
req.query.subscriptionId
)
await _removeUserFromGroup(req, res, {
userToRemoveId,
loggedInUserId: userToRemoveId,
subscription,
})
}
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @param {string} userToRemoveId
* @param {string} loggedInUserId
* @param {Subscription} subscription
* @returns {Promise<void>}
* @private
*/
async function _removeUserFromGroup(
req,
res,
{ userToRemoveId, loggedInUserId, subscription }
) {
const subscriptionId = subscription._id
const groupSSOActive = (
await Modules.promises.hooks.fire('hasGroupSSOEnabled', subscription)
)?.[0]
if (groupSSOActive) {
await Modules.promises.hooks.fire(
'unlinkUserFromGroupSSO',
userToRemoveId,
subscriptionId
)
}
try {
await UserAuditLogHandler.promises.addEntry(
userToRemoveId,
'remove-from-group-subscription',
loggedInUserId,
req.ip,
{ subscriptionId }
)
} catch (auditLogError) {
throw OError.tag(auditLogError, 'error adding audit log entry', {
userToRemoveId,
subscriptionId,
})
}
const groupAuditLog = {
initiatorId: loggedInUserId,
ipAddress: req.ip,
}
try {
await SubscriptionGroupHandler.promises.removeUserFromGroup(
subscriptionId,
userToRemoveId,
groupAuditLog
)
} catch (error) {
logger.err(
{ err: error, userToRemoveId, subscriptionId },
'error removing self from group'
)
return res.sendStatus(500)
}
res.sendStatus(200)
}
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function addSeatsToGroupSubscription(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const { subscription, paymentProviderSubscription, plan } =
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
userId
)
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
paymentProviderSubscription
)
await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
subscription
)
await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice(
subscription
)
await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
paymentProviderSubscription,
userId
)
await SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
paymentProviderSubscription
)
res.render('subscriptions/add-seats', {
subscriptionId: subscription._id,
groupName: subscription.teamName,
totalLicenses: subscription.membersLimit,
isProfessional: isProfessionalGroupPlan(subscription),
isCollectionMethodManual:
paymentProviderSubscription.isCollectionMethodManual,
})
} catch (error) {
if (error instanceof MissingBillingInfoError) {
return res.redirect(
'/user/subscription/group/missing-billing-information'
)
}
if (error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError) {
return res.redirect(
'/user/subscription/group/manually-collected-subscription'
)
}
if (
error instanceof PendingChangeError ||
error instanceof InactiveError ||
error instanceof HasPastDueInvoiceError
) {
return res.redirect('/user/subscription')
}
logger.err(
{ error },
'error while getting users group subscription details'
)
return res.redirect('/user/subscription')
}
}
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function previewAddSeatsSubscriptionChange(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const preview =
await SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange(
userId,
req.body.adding
)
res.json(preview)
} catch (error) {
if (
error instanceof MissingBillingInfoError ||
error instanceof PendingChangeError ||
error instanceof InactiveError ||
error instanceof HasPastDueInvoiceError ||
error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError
) {
return res.status(422).end()
}
if (error instanceof SubtotalLimitExceededError) {
return res.status(422).json({
code: 'subtotal_limit_exceeded',
adding: req.body.adding,
})
}
logger.err(
{ error },
'error trying to preview "add seats" subscription change'
)
return res.status(500).end()
}
}
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function createAddSeatsSubscriptionChange(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const create =
await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
userId,
req.body.adding,
req.body.poNumber
)
res.json(create)
} catch (error) {
if (
error instanceof MissingBillingInfoError ||
error instanceof PendingChangeError ||
error instanceof InactiveError ||
error instanceof HasPastDueInvoiceError ||
error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError
) {
return res.status(422).end()
}
if (error instanceof SubtotalLimitExceededError) {
return res.status(422).json({
code: 'subtotal_limit_exceeded',
adding: req.body.adding,
})
}
if (error instanceof PaymentActionRequiredError) {
return res.status(402).json({
message: 'Payment action required',
clientSecret: error.info.clientSecret,
publicKey: error.info.publicKey,
})
}
logger.err(
{ error },
'error trying to create "add seats" subscription change'
)
return res.status(500).end()
}
}
async function submitForm(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const userEmail = await UserGetter.promises.getUserEmail(userId)
const { adding, poNumber } = req.body
const { paymentProviderSubscription } =
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
userId
)
if (paymentProviderSubscription.isCollectionMethodManual) {
await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
paymentProviderSubscription,
poNumber
)
}
const messageLines = [`\n**Overleaf Sales Contact Form:**`]
messageLines.push('**Subject:** Self-Serve Group User Increase Request')
messageLines.push(`**Estimated Number of Users:** ${adding}`)
if (poNumber) {
messageLines.push(`**PO Number:** ${poNumber}`)
}
messageLines.push(
`**Message:** This email has been generated on behalf of user with email **${userEmail}** ` +
'to request an increase in the total number of users for their subscription.'
)
const messageFormatted = messageLines.join('\n\n')
const data = {
email: userEmail,
subject: 'Sales Contact Form',
message: messageFormatted,
inbox: 'sales',
}
await Modules.promises.hooks.fire('sendSupportRequest', data)
res.sendStatus(204)
}
async function subscriptionUpgradePage(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const changePreview =
await SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview(userId)
const olSubscription = await Subscription.findOne({
admin_id: userId,
}).exec()
res.render('subscriptions/upgrade-group-subscription-react', {
changePreview,
totalLicenses: olSubscription.membersLimit,
groupName: olSubscription.teamName,
})
} catch (error) {
if (error instanceof MissingBillingInfoError) {
return res.redirect(
'/user/subscription/group/missing-billing-information'
)
}
if (error instanceof ManuallyCollectedError) {
return res.redirect(
'/user/subscription/group/manually-collected-subscription'
)
}
if (error instanceof SubtotalLimitExceededError) {
return res.redirect('/user/subscription/group/subtotal-limit-exceeded')
}
if (error instanceof PendingChangeError || error instanceof InactiveError) {
return res.redirect('/user/subscription')
}
logger.err({ error }, 'error loading upgrade subscription page')
return res.redirect('/user/subscription')
}
}
async function upgradeSubscription(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
await SubscriptionGroupHandler.promises.upgradeGroupPlan(userId)
return res.sendStatus(200)
} catch (error) {
if (error instanceof PaymentActionRequiredError) {
return res.status(402).json({
message: 'Payment action required',
clientSecret: error.info.clientSecret,
publicKey: error.info.publicKey,
})
}
logger.err({ error }, 'error trying to upgrade subscription')
return res.sendStatus(500)
}
}
async function missingBillingInformation(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const subscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
res.render('subscriptions/missing-billing-information', {
groupName: subscription.teamName,
})
} catch (error) {
logger.err(
{ error },
'error trying to render missing billing information page'
)
return res.render('/user/subscription')
}
}
async function manuallyCollectedSubscription(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const subscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
res.render('subscriptions/manually-collected-subscription', {
groupName: subscription.teamName,
})
} catch (error) {
logger.err(
{ error },
'error trying to render manually collected subscription page'
)
return res.render('/user/subscription')
}
}
async function subtotalLimitExceeded(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const subscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
res.render('subscriptions/subtotal-limit-exceeded', {
groupName: subscription.teamName,
})
} catch (error) {
logger.err({ error }, 'error trying to render subtotal limit exceeded page')
return res.render('/user/subscription')
}
}
async function getGroupPlanPerUserPrices(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const prices = await Modules.promises.hooks.fire(
'getGroupPlanPerUserPrices',
userId,
req.query.currency
)
return res.json(prices[0])
} catch (error) {
logger.err({ error }, 'error trying to get websale group product prices')
return res.sendStatus(500)
}
}
export default {
removeUserFromGroup: expressify(removeUserFromGroup),
removeSelfFromGroup: expressify(removeSelfFromGroup),
addSeatsToGroupSubscription: expressify(addSeatsToGroupSubscription),
submitForm: expressify(submitForm),
previewAddSeatsSubscriptionChange: expressify(
previewAddSeatsSubscriptionChange
),
createAddSeatsSubscriptionChange: expressify(
createAddSeatsSubscriptionChange
),
subscriptionUpgradePage: expressify(subscriptionUpgradePage),
upgradeSubscription: expressify(upgradeSubscription),
missingBillingInformation: expressify(missingBillingInformation),
manuallyCollectedSubscription: expressify(manuallyCollectedSubscription),
subtotalLimitExceeded: expressify(subtotalLimitExceeded),
getGroupPlanPerUserPrices: expressify(getGroupPlanPerUserPrices),
}