Merge pull request #22518 from overleaf/ii-flexible-group-licensing-add-seats-legacy

[web] Unlock self-served license purchasing for legacy plans

GitOrigin-RevId: bf3083d00a77417f0e78d2145f6192c57b163273
This commit is contained in:
Liangjun Song
2025-01-28 11:36:02 +00:00
committed by Copybot
parent 8808e8dfa2
commit 6245e81f42
12 changed files with 1223 additions and 346 deletions
@@ -42,7 +42,7 @@ for (const [usage, planData] of Object.entries(groups)) {
// Generate plans in settings
for (const size of sizes) {
Settings.plans.push({
let plan = {
planCode: `group_${planCode}_${size}_${usage}`,
name: `${
Settings.appName
@@ -55,8 +55,19 @@ for (const [usage, planData] of Object.entries(groups)) {
features: Settings.features[planCode],
groupPlan: true,
membersLimit: parseInt(size),
membersLimitAddOn: 'additional-license',
})
// Unlock flexible licensing for all plans
canUseFlexibleLicensing: true,
}
// Add the `membersLimitAddOn` only to group plans of 5 or greater size
if (size >= 5) {
plan = {
...plan,
membersLimitAddOn: 'additional-license',
}
}
Settings.plans.push(plan)
}
}
}
@@ -247,7 +247,8 @@ function subscriptionFromApi(apiSubscription) {
apiSubscription.total == null ||
apiSubscription.currency == null ||
apiSubscription.currentPeriodStartedAt == null ||
apiSubscription.currentPeriodEndsAt == null
apiSubscription.currentPeriodEndsAt == null ||
apiSubscription.createdAt == null
) {
throw new OError('Invalid Recurly subscription', {
subscription: apiSubscription,
@@ -268,6 +269,7 @@ function subscriptionFromApi(apiSubscription) {
currency: apiSubscription.currency,
periodStart: apiSubscription.currentPeriodStartedAt,
periodEnd: apiSubscription.currentPeriodEndsAt,
createdAt: apiSubscription.createdAt,
})
if (apiSubscription.pendingChange != null) {
@@ -6,6 +6,7 @@ const PlansLocator = require('./PlansLocator')
const SubscriptionHelper = require('./SubscriptionHelper')
const AI_ADD_ON_CODE = 'assistant'
const MEMBERS_LIMIT_ADD_ON_CODE = 'additional-license'
const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
class RecurlySubscription {
@@ -24,6 +25,7 @@ class RecurlySubscription {
* @param {number} props.total
* @param {Date} props.periodStart
* @param {Date} props.periodEnd
* @param {Date} props.createdAt
* @param {RecurlySubscriptionChange} [props.pendingChange]
*/
constructor(props) {
@@ -40,6 +42,7 @@ class RecurlySubscription {
this.total = props.total
this.periodStart = props.periodStart
this.periodEnd = props.periodEnd
this.createdAt = props.createdAt
this.pendingChange = props.pendingChange ?? null
}
@@ -129,12 +132,13 @@ class RecurlySubscription {
*
* @param {string} code
* @param {number} [quantity]
* @param {number} [unitPrice]
* @return {RecurlySubscriptionChangeRequest} - the change request to send to
* Recurly
*
* @throws {DuplicateAddOnError} if the add-on is already present on the subscription
*/
getRequestForAddOnPurchase(code, quantity = 1) {
getRequestForAddOnPurchase(code, quantity = 1, unitPrice) {
if (this.hasAddOn(code)) {
throw new DuplicateAddOnError('Subscription already has add-on', {
subscriptionId: this.id,
@@ -143,7 +147,9 @@ class RecurlySubscription {
}
const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
addOnUpdates.push(
new RecurlySubscriptionAddOnUpdate({ code, quantity, unitPrice })
)
return new RecurlySubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
@@ -431,6 +437,7 @@ function isStandaloneAiAddOnPlanCode(planCode) {
module.exports = {
AI_ADD_ON_CODE,
MEMBERS_LIMIT_ADD_ON_CODE,
RecurlySubscription,
RecurlySubscriptionAddOn,
RecurlySubscriptionChange,
@@ -128,6 +128,7 @@ async function addSeatsToGroupSubscription(req, res) {
req
)
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
await SubscriptionGroupHandler.promises.ensureAddSeatsEnabled(plan)
res.render('subscriptions/add-seats', {
subscriptionId: subscription._id,
@@ -7,6 +7,8 @@ const SessionManager = require('../Authentication/SessionManager')
const RecurlyClient = require('./RecurlyClient')
const PlansLocator = require('./PlansLocator')
const SubscriptionHandler = require('./SubscriptionHandler')
const GroupPlansData = require('./GroupPlansData')
const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./RecurlyEntities')
async function removeUserFromGroup(subscriptionId, userIdToRemove) {
await SubscriptionUpdater.promises.removeUserFromGroup(
@@ -57,7 +59,13 @@ async function _replaceInArray(model, property, oldValue, newValue) {
async function ensureFlexibleLicensingEnabled(plan) {
if (!plan?.canUseFlexibleLicensing) {
throw new Error('The group plan does not support flexible licencing')
throw new Error('The group plan does not support flexible licensing')
}
}
async function ensureAddSeatsEnabled(plan) {
if (!plan?.membersLimitAddOn) {
throw new Error('The group plan does not support adding seats')
}
}
@@ -88,29 +96,60 @@ async function _addSeatsSubscriptionChange(req) {
const { recurlySubscription, plan } =
await getUsersGroupSubscriptionDetails(req)
await ensureFlexibleLicensingEnabled(plan)
await ensureAddSeatsEnabled(plan)
const userId = SessionManager.getLoggedInUserId(req.session)
const currentAddonQuantity =
recurlySubscription.addOns.find(
addOn => addOn.code === plan.membersLimitAddOn
addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE
)?.quantity ?? 0
// Keeps only the new total quantity of addon
const nextAddonQuantity = currentAddonQuantity + adding
const changeRequest = recurlySubscription.getRequestForAddOnUpdate(
plan.membersLimitAddOn,
nextAddonQuantity
)
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 newPlanPricesAppliedAt = new Date('2025-01-08T14:00:00Z')
const isLegacyPriceApplicable =
new Date(recurlySubscription.createdAt) < newPlanPricesAppliedAt
if (isLegacyPriceApplicable) {
const pattern =
/^group_(collaborator|professional)_(5|10|20|50)_(educational|enterprise)$/
const [, planCode, size, usage] = plan.planCode.match(pattern)
const currency = recurlySubscription.currency
const legacyPriceInCents =
GroupPlansData[usage][planCode][currency][size]
.additional_license_legacy_price_in_cents
if (legacyPriceInCents > 0) {
unitPrice = legacyPriceInCents / 100
}
}
changeRequest = recurlySubscription.getRequestForAddOnPurchase(
MEMBERS_LIMIT_ADD_ON_CODE,
nextAddonQuantity,
unitPrice
)
}
return {
changeRequest,
userId,
currentAddonQuantity,
recurlySubscription,
plan,
}
}
async function previewAddSeatsSubscriptionChange(req) {
const { changeRequest, userId, currentAddonQuantity, plan } =
const { changeRequest, userId, currentAddonQuantity } =
await _addSeatsSubscriptionChange(req)
const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
const subscriptionChange =
@@ -120,9 +159,9 @@ async function previewAddSeatsSubscriptionChange(req) {
{
type: 'add-on-update',
addOn: {
code: plan.membersLimitAddOn,
code: MEMBERS_LIMIT_ADD_ON_CODE,
quantity: subscriptionChange.nextAddOns.find(
addon => addon.code === plan.membersLimitAddOn
addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE
).quantity,
prevQuantity: currentAddonQuantity,
},
@@ -216,6 +255,7 @@ module.exports = {
removeUserFromGroup: callbackify(removeUserFromGroup),
replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
ensureAddSeatsEnabled: callbackify(ensureAddSeatsEnabled),
getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
isUserPartOfGroup: callbackify(isUserPartOfGroup),
getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
@@ -224,6 +264,7 @@ module.exports = {
removeUserFromGroup,
replaceUserReferencesInGroups,
ensureFlexibleLicensingEnabled,
ensureAddSeatsEnabled,
getTotalConfirmedUsersInGroup,
isUserPartOfGroup,
getUsersGroupSubscriptionDetails,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
{
"educational": {
"professional": {
"5": {
"AUD": 321,
"BRL": 699,
"CAD": 314,
"CHF": 279,
"CLP": 168693,
"COP": 552930,
"DKK": 1665,
"EUR": 258,
"GBP": 223,
"INR": 6719,
"MXN": 4129,
"NOK": 2008,
"NZD": 321,
"PEN": 671,
"SEK": 2008,
"SGD": 363,
"USD": 279
},
"10": {
"AUD": 179,
"BRL": 389,
"CAD": 175,
"CHF": 155,
"CLP": 93986,
"COP": 308061,
"DKK": 927,
"EUR": 143,
"GBP": 124,
"INR": 3743,
"MXN": 2300,
"NOK": 1118,
"NZD": 179,
"PEN": 374,
"SEK": 1118,
"SGD": 202,
"USD": 155
},
"20": {
"AUD": 165,
"BRL": 359,
"CAD": 161,
"CHF": 143,
"CLP": 86756,
"COP": 284364,
"DKK": 856,
"EUR": 132,
"GBP": 114,
"INR": 3455,
"MXN": 2123,
"NOK": 1032,
"NZD": 165,
"PEN": 345,
"SEK": 1032,
"SGD": 186,
"USD": 143
},
"50": {
"AUD": 151,
"BRL": 329,
"CAD": 148,
"CHF": 131,
"CLP": 79526,
"COP": 260667,
"DKK": 785,
"EUR": 121,
"GBP": 105,
"INR": 3167,
"MXN": 1946,
"NOK": 946,
"NZD": 151,
"PEN": 316,
"SEK": 946,
"SGD": 171,
"USD": 131
}
},
"collaborator": {
"5": {
"AUD": 167,
"BRL": 349,
"CAD": 160,
"CHF": 139,
"CLP": 77693,
"COP": 272930,
"DKK": 839,
"EUR": 125,
"GBP": 111,
"INR": 3219,
"MXN": 2029,
"NOK": 1014,
"NZD": 167,
"PEN": 321,
"SEK": 1014,
"SGD": 181,
"USD": 139
},
"10": {
"AUD": 93,
"BRL": 194,
"CAD": 89,
"CHF": 77,
"CLP": 43286,
"COP": 152061,
"DKK": 467,
"EUR": 69,
"GBP": 62,
"INR": 1793,
"MXN": 1130,
"NOK": 565,
"NZD": 93,
"PEN": 179,
"SEK": 565,
"SGD": 101,
"USD": 77
},
"20": {
"AUD": 86,
"BRL": 179,
"CAD": 82,
"CHF": 71,
"CLP": 39956,
"COP": 140364,
"DKK": 431,
"EUR": 64,
"GBP": 57,
"INR": 1655,
"MXN": 1043,
"NOK": 521,
"NZD": 86,
"PEN": 165,
"SEK": 521,
"SGD": 93,
"USD": 71
},
"50": {
"AUD": 78,
"BRL": 164,
"CAD": 75,
"CHF": 65,
"CLP": 36626,
"COP": 128667,
"DKK": 395,
"EUR": 59,
"GBP": 52,
"INR": 1517,
"MXN": 956,
"NOK": 478,
"NZD": 78,
"PEN": 151,
"SEK": 478,
"SGD": 85,
"USD": 65
}
}
},
"enterprise": {
"professional": {
"5": {
"AUD": 321,
"BRL": 699,
"CAD": 314,
"CHF": 499,
"CLP": 168693,
"COP": 552930,
"DKK": 1665,
"EUR": 258,
"GBP": 223,
"INR": 6719,
"MXN": 4129,
"NOK": 2008,
"NZD": 321,
"PEN": 671,
"SEK": 2008,
"SGD": 363,
"USD": 279
},
"10": {
"AUD": 298,
"BRL": 649,
"CAD": 291,
"CHF": 259,
"CLP": 156643,
"COP": 513435,
"DKK": 1546,
"EUR": 239,
"GBP": 207,
"INR": 6239,
"MXN": 3834,
"NOK": 1864,
"NZD": 298,
"PEN": 623,
"SEK": 1864,
"SGD": 337,
"USD": 259
},
"20": {
"AUD": 275,
"BRL": 599,
"CAD": 269,
"CHF": 239,
"CLP": 144594,
"COP": 473940,
"DKK": 1427,
"EUR": 221,
"GBP": 191,
"INR": 5759,
"MXN": 3539,
"NOK": 1721,
"NZD": 275,
"PEN": 575,
"SEK": 1721,
"SGD": 311,
"USD": 239
},
"50": {
"AUD": 252,
"BRL": 549,
"CAD": 246,
"CHF": 219,
"CLP": 132544,
"COP": 400000,
"DKK": 1308,
"EUR": 202,
"GBP": 175,
"INR": 5279,
"MXN": 3244,
"NOK": 1577,
"NZD": 252,
"PEN": 527,
"SEK": 1577,
"SGD": 285,
"USD": 219
}
},
"collaborator": {
"5": {
"AUD": 167,
"BRL": 349,
"CAD": 160,
"CHF": 139,
"CLP": 77693,
"COP": 272930,
"DKK": 839,
"EUR": 125,
"GBP": 111,
"INR": 3219,
"MXN": 2029,
"NOK": 1014,
"NZD": 167,
"PEN": 321,
"SEK": 1014,
"SGD": 181,
"USD": 139
},
"10": {
"AUD": 155,
"BRL": 324,
"CAD": 148,
"CHF": 129,
"CLP": 72143,
"COP": 253435,
"DKK": 779,
"EUR": 116,
"GBP": 103,
"INR": 2989,
"MXN": 1884,
"NOK": 941,
"NZD": 155,
"PEN": 298,
"SEK": 941,
"SGD": 168,
"USD": 129
},
"20": {
"AUD": 143,
"BRL": 299,
"CAD": 137,
"CHF": 119,
"CLP": 66594,
"COP": 233940,
"DKK": 719,
"EUR": 107,
"GBP": 95,
"INR": 2759,
"MXN": 1739,
"NOK": 869,
"NZD": 143,
"PEN": 275,
"SEK": 869,
"SGD": 155,
"USD": 119
},
"50": {
"AUD": 131,
"BRL": 274,
"CAD": 125,
"CHF": 109,
"CLP": 61044,
"COP": 214445,
"DKK": 659,
"EUR": 98,
"GBP": 87,
"INR": 2529,
"MXN": 1594,
"NOK": 796,
"NZD": 131,
"PEN": 252,
"SEK": 796,
"SGD": 142,
"USD": 109
}
}
}
}
@@ -135,6 +135,16 @@ function generateGroupPlans(workSheetJSON) {
)
const sizes = ['2', '3', '4', '5', '10', '20', '50']
const additionalLicenseAddOnLegacyPricesFilePath = path.resolve(
__dirname,
'additional-license-add-on-legacy-prices.json'
)
const additionalLicenseAddOnLegacyPricesFile = fs.readFileSync(
additionalLicenseAddOnLegacyPricesFilePath
)
const additionalLicenseAddOnLegacyPrices = JSON.parse(
additionalLicenseAddOnLegacyPricesFile
)
const result = {}
for (const type1 of ['educational', 'enterprise']) {
@@ -152,6 +162,15 @@ function generateGroupPlans(workSheetJSON) {
result[type1][type2][currency][size] = {
price_in_cents: plan[currency] * 100,
}
const additionalLicenseAddOnLegacyPrice =
additionalLicenseAddOnLegacyPrices[type1][type2][size]?.[currency]
if (additionalLicenseAddOnLegacyPrice) {
Object.assign(result[type1][type2][currency][size], {
additional_license_legacy_price_in_cents:
additionalLicenseAddOnLegacyPrice * 100,
})
}
}
}
}
@@ -50,6 +50,7 @@ describe('RecurlyClient', function () {
total: 16.5,
periodStart: new Date(),
periodEnd: new Date(),
createdAt: new Date(),
})
this.recurlySubscription = {
@@ -79,6 +80,7 @@ describe('RecurlyClient', function () {
currency: this.subscription.currency,
currentPeriodStartedAt: this.subscription.periodStart,
currentPeriodEndsAt: this.subscription.periodEnd,
createdAt: this.subscription.createdAt,
}
this.recurlySubscriptionChange = new recurly.SubscriptionChange()
@@ -185,6 +185,38 @@ describe('RecurlyEntities', function () {
)
})
it('returns a change request with quantity and unit price specified', function () {
const {
RecurlySubscriptionChangeRequest,
RecurlySubscriptionAddOnUpdate,
} = this.RecurlyEntities
const quantity = 5
const unitPrice = 10
const changeRequest = this.subscription.getRequestForAddOnPurchase(
'another-add-on',
quantity,
unitPrice
)
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: this.addOn.quantity,
unitPrice: this.addOn.unitPrice,
}),
new RecurlySubscriptionAddOnUpdate({
code: 'another-add-on',
quantity,
unitPrice,
}),
],
})
)
})
it('throws a DuplicateAddOnError if the subscription already has the add-on', function () {
expect(() =>
this.subscription.getRequestForAddOnPurchase(this.addOn.code)
@@ -346,6 +378,7 @@ describe('RecurlyEntities', function () {
total: 11.5,
periodStart: new Date(),
periodEnd: new Date(),
createdAt: new Date(),
})
const change = new RecurlySubscriptionChange({
subscription,
@@ -56,6 +56,7 @@ describe('SubscriptionGroupController', function () {
.stub()
.resolves(this.createSubscriptionChangeData),
ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
ensureAddSeatsEnabled: sinon.stub().resolves(),
getGroupPlanUpgradePreview: sinon
.stub()
.resolves(this.previewSubscriptionChangeData),
@@ -336,6 +337,9 @@ describe('SubscriptionGroupController', function () {
this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
.calledWith(this.plan)
.should.equal(true)
this.SubscriptionGroupHandler.promises.ensureAddSeatsEnabled
.calledWith(this.plan)
.should.equal(true)
page.should.equal('subscriptions/add-seats')
props.subscriptionId.should.equal(this.subscriptionId)
props.groupName.should.equal(this.subscription.teamName)
@@ -375,6 +379,21 @@ describe('SubscriptionGroupController', function () {
this.Controller.addSeatsToGroupSubscription(this.req, res)
})
it('should redirect to subscription page when "add seats" is not enabled', function (done) {
this.SubscriptionGroupHandler.promises.ensureAddSeatsEnabled = sinon
.stub()
.rejects()
const res = {
redirect: url => {
url.should.equal('/user/subscription')
done()
},
}
this.Controller.addSeatsToGroupSubscription(this.req, res)
})
})
describe('previewAddSeatsSubscriptionChange', function () {
@@ -15,9 +15,12 @@ describe('SubscriptionGroupHandler', function () {
this.subscription_id = '31DSd1123D'
this.adding = 1
this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
this.RecurlyEntities = {
MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
}
this.localPlanInSettings = {
membersLimit: 2,
membersLimitAddOn: 'additional-license',
membersLimit: 5,
membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
}
this.subscription = {
@@ -37,12 +40,21 @@ describe('SubscriptionGroupHandler', function () {
id: 123,
addOns: [
{
code: 'additional-license',
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 1,
},
],
getRequestForAddOnUpdate: sinon.stub().returns(this.changeRequest),
getRequestForGroupPlanUpgrade: sinon.stub().returns(this.changeRequest),
getRequestForAddOnPurchase: sinon.stub().returns(this.changeRequest),
getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
.stub()
.returns(this.changeRequest),
createdAt: '2025-01-01T00:00:00Z',
currency: 'USD',
hasAddOn(code) {
return this.addOns.some(addOn => addOn.code === code)
},
}
this.SubscriptionLocator = {
@@ -81,7 +93,7 @@ describe('SubscriptionGroupHandler', function () {
this.previewSubscriptionChange = {
nextAddOns: [
{
code: 'additional-license',
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.recurlySubscription.addOns[0].quantity + this.adding,
},
],
@@ -115,6 +127,19 @@ describe('SubscriptionGroupHandler', function () {
},
}
this.GroupPlansData = {
enterprise: {
collaborator: {
USD: {
5: {
price_in_cents: 10000,
additional_license_legacy_price_in_cents: 5000,
},
},
},
},
}
this.Handler = SandboxedModule.require(modulePath, {
requires: {
'./SubscriptionUpdater': this.SubscriptionUpdater,
@@ -126,7 +151,9 @@ describe('SubscriptionGroupHandler', function () {
},
'./RecurlyClient': this.RecurlyClient,
'./PlansLocator': this.PlansLocator,
'./RecurlyEntities': this.RecurlyEntities,
'../Authentication/SessionManager': this.SessionManager,
'./GroupPlansData': this.GroupPlansData,
},
})
})
@@ -274,8 +301,8 @@ describe('SubscriptionGroupHandler', function () {
expect(data).to.deep.equal({
subscription: { groupPlan: true },
plan: {
membersLimit: 2,
membersLimitAddOn: 'additional-license',
membersLimit: 5,
membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
canUseFlexibleLicensing: true,
},
recurlySubscription: this.recurlySubscription,
@@ -293,60 +320,169 @@ describe('SubscriptionGroupHandler', function () {
})
})
afterEach(function () {
this.recurlySubscription.getRequestForAddOnUpdate
.calledWith(
'additional-license',
this.recurlySubscription.addOns[0].quantity + this.adding
describe('has "additional-license" add-on', function () {
beforeEach(function () {
this.recurlySubscription.addOns = [
{
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 6,
},
]
this.prevQuantity = this.recurlySubscription.addOns[0].quantity
this.previewSubscriptionChange.nextAddOns = [
{
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.prevQuantity + this.adding,
},
]
})
afterEach(function () {
sinon.assert.notCalled(
this.recurlySubscription.getRequestForAddOnPurchase
)
.should.equal(true)
})
describe('previewAddSeatsSubscriptionChange', function () {
it('should return the subscription change preview', async function () {
const preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.req
)
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.user_id)
.should.equal(true)
this.RecurlyClient.promises.previewSubscriptionChange
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
this.recurlySubscription.getRequestForAddOnUpdate
.calledWith(
{
type: 'add-on-update',
addOn: {
code: 'additional-license',
quantity:
this.recurlySubscription.addOns[0].quantity + this.adding,
prevQuantity: this.adding,
},
},
this.previewSubscriptionChange,
this.paymentMethod
this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.recurlySubscription.addOns[0].quantity + this.adding
)
.should.equal(true)
preview.should.equal(this.changePreview)
})
describe('previewAddSeatsSubscriptionChange', function () {
it('should return the subscription change preview', async function () {
const preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.req
)
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.user_id)
.should.equal(true)
this.RecurlyClient.promises.previewSubscriptionChange
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
.calledWith(
{
type: 'add-on-update',
addOn: {
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
this.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: this.prevQuantity,
},
},
this.previewSubscriptionChange,
this.paymentMethod
)
.should.equal(true)
preview.should.equal(this.changePreview)
})
})
describe('createAddSeatsSubscriptionChange', function () {
it('should change the subscription', async function () {
const result =
await this.Handler.promises.createAddSeatsSubscriptionChange(
this.req
)
this.RecurlyClient.promises.applySubscriptionChangeRequest
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionHandler.promises.syncSubscription
.calledWith({ uuid: this.recurlySubscription.id }, this.user_id)
.should.equal(true)
expect(result).to.deep.equal({
adding: this.req.body.adding,
})
})
})
})
describe('createAddSeatsSubscriptionChange', function () {
it('should change the subscription', async function () {
const result =
await this.Handler.promises.createAddSeatsSubscriptionChange(this.req)
describe('has no "additional-license" add-on', function () {
beforeEach(function () {
this.recurlySubscription.addOns = []
this.prevQuantity = this.recurlySubscription.addOns[0]?.quantity ?? 0
this.previewSubscriptionChange.nextAddOns = [
{
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.prevQuantity + this.adding,
},
]
this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...this.localPlanInSettings,
planCode: 'group_collaborator_5_enterprise',
canUseFlexibleLicensing: true,
})
})
this.RecurlyClient.promises.applySubscriptionChangeRequest
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionHandler.promises.syncSubscription
.calledWith({ uuid: this.recurlySubscription.id }, this.user_id)
.should.equal(true)
expect(result).to.deep.equal({
adding: this.req.body.adding,
afterEach(function () {
sinon.assert.notCalled(
this.recurlySubscription.getRequestForAddOnUpdate
)
})
describe('previewAddSeatsSubscriptionChange', function () {
let preview
afterEach(function () {
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.user_id)
.should.equal(true)
this.RecurlyClient.promises.previewSubscriptionChange
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
.calledWith(
{
type: 'add-on-update',
addOn: {
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
this.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: this.prevQuantity,
},
},
this.previewSubscriptionChange,
this.paymentMethod
)
.should.equal(true)
preview.should.equal(this.changePreview)
})
it('should return the subscription change preview with legacy add-on price', async function () {
this.recurlySubscription.createdAt = '2025-01-01T00:00:00Z'
preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.req
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
this.GroupPlansData.enterprise.collaborator.USD[5]
.additional_license_legacy_price_in_cents / 100
)
.should.equal(true)
})
it('should return the subscription change preview with non-legacy add-on price', async function () {
this.recurlySubscription.createdAt = '2030-01-01T00:00:00Z'
preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.req
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
undefined
)
.should.equal(true)
})
})
})
@@ -358,16 +494,32 @@ describe('SubscriptionGroupHandler', function () {
this.Handler.promises.ensureFlexibleLicensingEnabled({
canUseFlexibleLicensing: false,
})
).to.be.rejectedWith('The group plan does not support flexible licencing')
).to.be.rejectedWith('The group plan does not support flexible licensing')
})
it('should not throw if the subscription can use flexible licensing', async function () {
await expect(
this.Handler.promises.ensureFlexibleLicensingEnabled({
canUseFlexibleLicensing: true,
})
).to.not.be.rejected
})
})
it('should not throw if the subscription can use flexible licensing', async function () {
await expect(
this.Handler.promises.ensureFlexibleLicensingEnabled({
canUseFlexibleLicensing: true,
})
).to.not.be.rejected
describe('ensureAddSeatsEnabled', function () {
it('should throw if the subscription can not use the "add seats" feature', async function () {
await expect(
this.Handler.promises.ensureAddSeatsEnabled({})
).to.be.rejectedWith('The group plan does not support adding seats')
})
it('should not throw if the subscription can use the "add seats" feature', async function () {
await expect(
this.Handler.promises.ensureAddSeatsEnabled({
membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
})
).to.not.be.rejected
})
})
describe('upgradeGroupPlan', function () {