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-29 09:05:25 +00:00
committed by Copybot
parent 8808e8dfa2
commit 6245e81f42
12 changed files with 1223 additions and 346 deletions
@@ -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 () {