[web] fix bug where pending downgrades are removed when subscriptions change (#30420)

* preserve pending changes when generating change requests
* re-apply pending term_end changes after immediate updates
* block changes when Stripe subscription has multiple phases
* handle MultiplePendingChangesError & rm PendingChangeError

GitOrigin-RevId: 0af11044766ff48e683d684ad6d62b732d17290c
This commit is contained in:
Kristina
2026-02-03 09:05:55 +00:00
committed by Copybot
parent ee4b5f515c
commit 4c5cdecffa
19 changed files with 1179 additions and 129 deletions
@@ -66,7 +66,6 @@ describe('SubscriptionGroupController', function () {
ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
ensureSubscriptionIsActive: sinon.stub().resolves(),
ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(),
ensureSubscriptionHasNoPendingChanges: sinon.stub().resolves(),
ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(),
getGroupPlanUpgradePreview: sinon
.stub()
@@ -132,7 +131,6 @@ describe('SubscriptionGroupController', function () {
ctx.Errors = {
MissingBillingInfoError: class extends Error {},
ManuallyCollectedError: class extends Error {},
PendingChangeError: class extends Error {},
InactiveError: class extends Error {},
SubtotalLimitExceededError: class extends Error {},
HasPastDueInvoiceError: class extends Error {},
@@ -143,6 +141,7 @@ describe('SubscriptionGroupController', function () {
this.info = info
}
},
MultiplePendingChangesError: class extends Error {},
}
vi.doMock(
@@ -440,9 +439,6 @@ describe('SubscriptionGroupController', function () {
ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
.calledWith(ctx.plan)
.should.equal(true)
ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges
.calledWith(ctx.recurlySubscription)
.should.equal(true)
ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
.calledWith(ctx.subscription)
.should.equal(true)
@@ -542,22 +538,6 @@ describe('SubscriptionGroupController', function () {
})
})
it('should redirect to subscription page when there is a pending change', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
sinon.stub().throws(new ctx.Errors.PendingChangeError())
const res = {
redirect: url => {
url.should.equal('/user/subscription')
resolve()
},
}
ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
})
})
it('should redirect to subscription page when subscription is not active', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon
@@ -756,6 +736,28 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
})
})
it('should return 422 with MultiplePendingChangesError', async function (ctx) {
await new Promise(resolve => {
ctx.req.body = { adding: 2 }
ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
sinon.stub().throws(new ctx.Errors.MultiplePendingChangesError())
const res = {
status: statusCode => {
statusCode.should.equal(422)
return {
end: () => {
resolve()
},
}
},
}
ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
})
})
})
describe('submitForm', function () {
@@ -963,5 +965,30 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.upgradeSubscription(ctx.req, res)
})
})
it('should send 422 response with MultiplePendingChangesError', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
.stub()
.rejects(new ctx.Errors.MultiplePendingChangesError())
const res = {
status: code => {
code.should.equal(422)
return {
json: data => {
data.should.deep.equal({
code: 'multiple_pending_changes',
message:
'Cannot upgrade subscription while there are multiple pending subscription changes. Please contact support.',
})
resolve()
},
}
},
}
ctx.Controller.upgradeSubscription(ctx.req, res)
})
})
})
})