Merge pull request #26574 from overleaf/ls-group-plan-seats-adding-in-stripe

Support group plan seats adding in Stripe

GitOrigin-RevId: 9c46c167388c5578a1513f908e409ab5d940c1df
This commit is contained in:
Liangjun Song
2025-07-07 11:35:06 +01:00
committed by Copybot
parent 6629086f2f
commit 06cf395e37
5 changed files with 136 additions and 98 deletions
@@ -20,7 +20,6 @@ import {
SubtotalLimitExceededError,
HasPastDueInvoiceError,
} from './Errors.js'
import RecurlyClient from './RecurlyClient.js'
/**
* @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js"
@@ -138,13 +137,13 @@ async function _removeUserFromGroup(
async function addSeatsToGroupSubscription(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const { subscription, recurlySubscription, plan } =
const { subscription, paymentProviderSubscription, plan } =
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
userId
)
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
recurlySubscription
paymentProviderSubscription
)
await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
subscription
@@ -162,15 +161,15 @@ async function addSeatsToGroupSubscription(req, res) {
if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') {
await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
recurlySubscription,
paymentProviderSubscription,
userId
)
} else {
await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
recurlySubscription
paymentProviderSubscription
)
// Check if the user has missing billing details
await RecurlyClient.promises.getPaymentMethod(userId)
await Modules.promises.hooks.fire('getPaymentMethod', userId)
}
res.render('subscriptions/add-seats', {
@@ -178,7 +177,8 @@ async function addSeatsToGroupSubscription(req, res) {
groupName: subscription.teamName,
totalLicenses: subscription.membersLimit,
isProfessional: isProfessionalGroupPlan(subscription),
isCollectionMethodManual: recurlySubscription.isCollectionMethodManual,
isCollectionMethodManual:
paymentProviderSubscription.isCollectionMethodManual,
})
} catch (error) {
if (error instanceof MissingBillingInfoError) {
@@ -300,15 +300,15 @@ async function submitForm(req, res) {
const userEmail = await UserGetter.promises.getUserEmail(userId)
const { adding, poNumber } = req.body
const { recurlySubscription } =
const { paymentProviderSubscription } =
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
userId
)
if (recurlySubscription.isCollectionMethodManual) {
if (paymentProviderSubscription.isCollectionMethodManual) {
await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
userId,
recurlySubscription,
paymentProviderSubscription,
poNumber
)
}
@@ -9,7 +9,6 @@ const { Subscription } = require('../../models/Subscription')
const { User } = require('../../models/User')
const RecurlyClient = require('./RecurlyClient')
const PlansLocator = require('./PlansLocator')
const SubscriptionHandler = require('./SubscriptionHandler')
const TeamInvitesHandler = require('./TeamInvitesHandler')
const GroupPlansData = require('./GroupPlansData')
const Modules = require('../../infrastructure/Modules')
@@ -86,22 +85,24 @@ async function ensureSubscriptionIsActive(subscription) {
}
async function ensureSubscriptionCollectionMethodIsNotManual(
recurlySubscription
paymentProviderSubscription
) {
if (recurlySubscription.isCollectionMethodManual) {
if (paymentProviderSubscription.isCollectionMethodManual) {
throw new ManuallyCollectedError(
'This subscription is being collected manually',
{
recurlySubscription_id: recurlySubscription.id,
subscription_id: paymentProviderSubscription.id,
}
)
}
}
async function ensureSubscriptionHasNoPendingChanges(recurlySubscription) {
if (recurlySubscription.pendingChange) {
async function ensureSubscriptionHasNoPendingChanges(
paymentProviderSubscription
) {
if (paymentProviderSubscription.pendingChange) {
throw new PendingChangeError('This subscription has a pending change', {
recurlySubscription_id: recurlySubscription.id,
subscription_id: paymentProviderSubscription.id,
})
}
}
@@ -136,47 +137,50 @@ async function getUsersGroupSubscriptionDetails(userId) {
const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
const recurlySubscription = await RecurlyClient.promises.getSubscription(
subscription.recurlySubscription_id
const response = await Modules.promises.hooks.fire(
'getPaymentFromRecord',
subscription
)
const { subscription: paymentProviderSubscription } = response[0]
return {
userId,
subscription,
recurlySubscription,
paymentProviderSubscription,
plan,
}
}
async function checkBillingInfoExistence(recurlySubscription, userId) {
async function checkBillingInfoExistence(paymentProviderSubscription, userId) {
// Verify the billing info only if the collection method is not manual (e.g. automatic)
if (!recurlySubscription.isCollectionMethodManual) {
if (!paymentProviderSubscription.isCollectionMethodManual) {
// Check if the user has missing billing details
await RecurlyClient.promises.getPaymentMethod(userId)
await Modules.promises.hooks.fire('getPaymentMethod', userId)
}
}
async function _addSeatsSubscriptionChange(userId, adding) {
const { subscription, recurlySubscription, plan } =
const { subscription, paymentProviderSubscription, plan } =
await getUsersGroupSubscriptionDetails(userId)
await ensureFlexibleLicensingEnabled(plan)
await ensureSubscriptionIsActive(subscription)
await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
await checkBillingInfoExistence(recurlySubscription, userId)
await ensureSubscriptionHasNoPendingChanges(paymentProviderSubscription)
await checkBillingInfoExistence(paymentProviderSubscription, userId)
await ensureSubscriptionHasNoPastDueInvoice(subscription)
const currentAddonQuantity =
recurlySubscription.addOns.find(
paymentProviderSubscription.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)) {
if (paymentProviderSubscription.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(
changeRequest = paymentProviderSubscription.getRequestForAddOnUpdate(
MEMBERS_LIMIT_ADD_ON_CODE,
nextAddonQuantity
)
@@ -185,7 +189,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
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 currency = paymentProviderSubscription.currency
const planPriceInCents =
GroupPlansData[usage][planCode][currency][size].price_in_cents
const legacyUnitPriceInCents =
@@ -194,7 +198,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
if (
_shouldUseLegacyPricing(
recurlySubscription.planPrice,
paymentProviderSubscription.planPrice,
planPriceInCents / 100,
usage,
size
@@ -203,7 +207,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
unitPrice = legacyUnitPriceInCents / 100
}
changeRequest = recurlySubscription.getRequestForAddOnPurchase(
changeRequest = paymentProviderSubscription.getRequestForAddOnPurchase(
MEMBERS_LIMIT_ADD_ON_CODE,
nextAddonQuantity,
unitPrice
@@ -213,7 +217,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
return {
changeRequest,
currentAddonQuantity,
recurlySubscription,
paymentProviderSubscription,
}
}
@@ -237,37 +241,38 @@ function _shouldUseLegacyPricing(
async function previewAddSeatsSubscriptionChange(userId, adding) {
const { changeRequest, currentAddonQuantity } =
await _addSeatsSubscriptionChange(userId, adding)
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,
},
const response = await Modules.promises.hooks.fire(
'previewSubscriptionChangeRequest',
changeRequest
)
const subscriptionChange = response[0]
const subscriptionChangePreview = 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
)
},
subscriptionChange
)
return subscriptionChangePreview
}
async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
const { changeRequest, recurlySubscription } =
const { changeRequest, paymentProviderSubscription } =
await _addSeatsSubscriptionChange(userId, adding)
if (recurlySubscription.isCollectionMethodManual) {
await updateSubscriptionPaymentTerms(userId, recurlySubscription, poNumber)
if (paymentProviderSubscription.isCollectionMethodManual) {
await updateSubscriptionPaymentTerms(paymentProviderSubscription, poNumber)
}
await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
await SubscriptionHandler.promises.syncSubscription(
{ uuid: recurlySubscription.id },
await Modules.promises.hooks.fire(
'applySubscriptionChangeRequestAndSync',
changeRequest,
userId
)
@@ -275,21 +280,30 @@ async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
}
async function updateSubscriptionPaymentTerms(
userId,
recurlySubscription,
paymentProviderSubscription,
poNumber
) {
if (paymentProviderSubscription.service?.includes('stripe')) {
// TODO: Implement Stripe payment terms update
throw new OError(
'Updating payment terms is not supported for Stripe subscriptions',
{
subscriptionId: paymentProviderSubscription.id,
}
)
}
const [termsAndConditions] = await Modules.promises.hooks.fire(
'generateTermsAndConditions',
{ currency: recurlySubscription.currency, poNumber }
{ currency: paymentProviderSubscription.currency, poNumber }
)
const updateRequest = poNumber
? recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
? paymentProviderSubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
poNumber,
termsAndConditions
)
: recurlySubscription.getRequestForTermsAndConditionsUpdate(
: paymentProviderSubscription.getRequestForTermsAndConditionsUpdate(
termsAndConditions
)
@@ -74,7 +74,7 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
*/
async function previewSubscriptionChange(userId, planCode) {
const change = await Modules.promises.hooks.fire(
'previewSubscriptionChange',
'previewPlanChange',
userId,
planCode
)
@@ -55,7 +55,7 @@ describe('SubscriptionGroupController', function () {
getUsersGroupSubscriptionDetails: sinon.stub().resolves({
subscription: ctx.subscription,
plan: ctx.plan,
recurlySubscription: ctx.recurlySubscription,
paymentProviderSubscription: ctx.recurlySubscription,
}),
previewAddSeatsSubscriptionChange: sinon
.stub()
@@ -193,21 +193,26 @@ describe('SubscriptionGroupHandler', function () {
this.Modules = {
promises: {
hooks: {
fire: sinon.stub().callsFake(hookName => {
if (hookName === 'generateTermsAndConditions') {
return Promise.resolve(['T&Cs'])
}
if (hookName === 'getPaymentFromRecord') {
return Promise.resolve([
{ account: { hasPastDueInvoice: false } },
])
}
return Promise.resolve()
}),
fire: sinon.stub(),
},
},
}
this.Modules.promises.hooks.fire
.withArgs('generateTermsAndConditions')
.resolves(['T&Cs'])
.withArgs('getPaymentFromRecord')
.resolves([
{
subscription: this.recurlySubscription,
account: { hasPastDueInvoice: false },
},
])
.withArgs('previewSubscriptionChangeRequest')
.resolves([this.previewSubscriptionChange])
.withArgs('previewGroupPlanUpgrade')
.resolves([{ subscriptionChange: this.previewSubscriptionChange }])
this.Handler = SandboxedModule.require(modulePath, {
requires: {
'./SubscriptionUpdater': this.SubscriptionUpdater,
@@ -389,7 +394,7 @@ describe('SubscriptionGroupHandler', function () {
this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
canUseFlexibleLicensing: true,
},
recurlySubscription: this.recurlySubscription,
paymentProviderSubscription: this.recurlySubscription,
})
})
})
@@ -441,11 +446,16 @@ describe('SubscriptionGroupHandler', function () {
this.adminUser_id,
this.adding
)
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.adminUser_id)
this.Modules.promises.hooks.fire
.calledWith('getPaymentFromRecord', {
groupPlan: true,
recurlyStatus: {
state: 'active',
},
})
.should.equal(true)
this.RecurlyClient.promises.previewSubscriptionChange
.calledWith(this.changeRequest)
this.Modules.promises.hooks.fire
.calledWith('previewSubscriptionChangeRequest', this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
.calledWith(
@@ -473,9 +483,14 @@ describe('SubscriptionGroupHandler', function () {
return true
},
}
this.RecurlyClient.promises.getSubscription = sinon
.stub()
.resolves(this.recurlySubscription)
this.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord')
.resolves([
{
subscription: this.recurlySubscription,
account: { hasPastDueInvoice: false },
},
])
const result =
await this.Handler.promises.createAddSeatsSubscriptionChange(
@@ -491,13 +506,10 @@ describe('SubscriptionGroupHandler', function () {
.and(sinon.match.has('termsAndConditions'))
)
.should.equal(true)
this.RecurlyClient.promises.applySubscriptionChangeRequest
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionHandler.promises.syncSubscription
this.Modules.promises.hooks.fire
.calledWith(
{ uuid: this.recurlySubscription.id },
this.adminUser_id
'applySubscriptionChangeRequestAndSync',
this.changeRequest
)
.should.equal(true)
expect(result).to.deep.equal({
@@ -511,7 +523,6 @@ describe('SubscriptionGroupHandler', function () {
describe('accounts with PO number', function () {
it('should update the subscription PO number and T&C', async function () {
await this.Handler.promises.updateSubscriptionPaymentTerms(
this.adminUser_id,
this.recurlySubscription,
this.poNumberAndTermsAndConditionsUpdate.poNumber
)
@@ -525,12 +536,23 @@ describe('SubscriptionGroupHandler', function () {
.calledWith(this.poNumberAndTermsAndConditionsUpdate)
.should.equal(true)
})
it('should fail for stripe', async function () {
this.recurlySubscription.service = 'stripe'
await expect(
this.Handler.promises.updateSubscriptionPaymentTerms(
this.recurlySubscription,
this.poNumberAndTermsAndConditionsUpdate.poNumber
)
).to.be.rejectedWith(
'Updating payment terms is not supported for Stripe subscriptions'
)
})
})
describe('accounts with no PO number', function () {
it('should update the subscription T&C only', async function () {
await this.Handler.promises.updateSubscriptionPaymentTerms(
this.adminUser_id,
this.recurlySubscription
)
this.recurlySubscription.getRequestForTermsAndConditionsUpdate
@@ -570,11 +592,16 @@ describe('SubscriptionGroupHandler', function () {
let preview
afterEach(function () {
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.adminUser_id)
this.Modules.promises.hooks.fire
.calledWith('getPaymentFromRecord', {
groupPlan: true,
recurlyStatus: {
state: 'active',
},
})
.should.equal(true)
this.RecurlyClient.promises.previewSubscriptionChange
.calledWith(this.changeRequest)
this.Modules.promises.hooks.fire
.calledWith('previewSubscriptionChangeRequest', this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
.calledWith(
@@ -781,9 +808,6 @@ describe('SubscriptionGroupHandler', function () {
describe('getGroupPlanUpgradePreview', function () {
it('should generate preview for subscription upgrade', async function () {
this.Modules.promises.hooks.fire.resolves([
{ subscriptionChange: this.previewSubscriptionChange },
])
const result = await this.Handler.promises.getGroupPlanUpgradePreview(
this.user_id
)
@@ -797,8 +821,8 @@ describe('SubscriptionGroupHandler', function () {
this.recurlySubscription,
this.adminUser_id
)
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.adminUser_id)
this.Modules.promises.hooks.fire
.calledWith('getPaymentMethod', this.adminUser_id)
.should.equal(true)
})