[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
@@ -6,6 +6,7 @@ import {
annualActiveSubscription,
annualActiveSubscriptionEuro,
annualActiveSubscriptionPro,
pendingAddOnChange,
pendingSubscriptionChange,
} from '../../../../../fixtures/subscriptions'
import { ActiveSubscription } from '../../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
@@ -69,6 +70,17 @@ describe('<ChangePlanModal />', function () {
screen.getByRole('button', { name: 'Keep my current plan' })
})
it('renders "Your plan" when there is a pending add-on change but no plan change', async function () {
renderActiveSubscription(pendingAddOnChange)
const button = screen.getByRole('button', { name: 'Change plan' })
fireEvent.click(button)
await screen.findByText('Your plan')
expect(screen.queryByRole('button', { name: 'Keep my current plan' })).to.be
.null
})
it('does not render when Recurly did not load', function () {
const { container } = renderWithSubscriptionDashContext(
<ActiveSubscription subscription={annualActiveSubscription} />,
@@ -409,6 +409,63 @@ export const canceledSubscription: PaidSubscription = {
},
}
export const pendingAddOnChange: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
groupPlan: false,
membersLimit: 0,
_id: 'add-on-change-123',
admin_id: 'abc123',
teamInvites: [],
planCode: 'collaborator-annual',
plan: {
planCode: 'collaborator-annual',
name: 'Standard (Collaborator) Annual',
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
billingDetailsLink: '/user/subscription/payment/billing-details',
accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
trialEndsAt: null,
activeCoupons: [],
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '$199.00',
addOns: [
{
code: 'AI',
quantity: 1,
unitPrice: 1000,
name: 'AI Add-on',
},
],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
isEligibleForPause: false,
isEligibleForDowngradeUpsell: false,
},
pendingPlan: {
planCode: 'collaborator-annual',
name: 'Standard (Collaborator) Annual',
price_in_cents: 21900,
annual: true,
featureDescription: [],
},
}
export const pendingSubscriptionChange: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
@@ -104,6 +104,13 @@ describe('PaymentProviderEntities', function () {
subscription: ctx.subscription,
timeframe: 'now',
planCode: 'premium-plan',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: ctx.addOn.code,
quantity: ctx.addOn.quantity,
unitPrice: ctx.addOn.unitPrice,
}),
],
})
)
})
@@ -121,6 +128,13 @@ describe('PaymentProviderEntities', function () {
subscription: ctx.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: ctx.addOn.code,
quantity: ctx.addOn.quantity,
unitPrice: ctx.addOn.unitPrice,
}),
],
})
)
})
@@ -141,6 +155,13 @@ describe('PaymentProviderEntities', function () {
subscription: ctx.subscription,
timeframe: 'now',
planCode: 'cheap-plan',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: ctx.addOn.code,
quantity: ctx.addOn.quantity,
unitPrice: ctx.addOn.unitPrice,
}),
],
})
)
})
@@ -163,6 +184,7 @@ describe('PaymentProviderEntities', function () {
new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
unitPrice: ctx.addOn.unitPrice,
}),
],
})
@@ -187,6 +209,7 @@ describe('PaymentProviderEntities', function () {
new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
unitPrice: ctx.addOn.unitPrice,
}),
],
})
@@ -235,6 +258,11 @@ describe('PaymentProviderEntities', function () {
timeframe: 'now',
planCode: 'group_collaborator',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: ctx.addOn.code,
quantity: ctx.addOn.quantity,
unitPrice: ctx.addOn.unitPrice,
}),
new PaymentProviderSubscriptionAddOnUpdate({
code: 'additional-license',
quantity: 10,
@@ -262,18 +290,84 @@ describe('PaymentProviderEntities', function () {
timeframe: 'now',
planCode: 'group_collaborator',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: 'additional-license',
quantity: 10,
}),
new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
unitPrice: ctx.addOn.unitPrice,
}),
new PaymentProviderSubscriptionAddOnUpdate({
code: 'additional-license',
quantity: 10,
}),
],
})
)
})
describe('with pending add-on removal', function () {
beforeEach(function (ctx) {
// Reset to a subscription with an add-on and pending removal
const {
PaymentProviderSubscription,
PaymentProviderSubscriptionAddOn,
} = ctx.PaymentProviderEntities
ctx.addOn = new PaymentProviderSubscriptionAddOn({
code: 'add-on-code',
name: 'My Add-On',
quantity: 1,
unitPrice: 2,
})
ctx.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
planName: 'My Plan',
planPrice: 10,
addOns: [ctx.addOn],
subtotal: 10.99,
taxRate: 0.2,
taxAmount: 2.4,
total: 14.4,
currency: 'USD',
})
// Set up a pending change that removes the add-on at term_end
ctx.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscription,
nextPlanCode: ctx.subscription.planCode,
nextPlanName: ctx.subscription.planName,
nextPlanPrice: ctx.subscription.planPrice,
nextAddOns: [], // Add-on is scheduled to be removed
})
})
it('preserves current add-ons for immediate upgrade', function (ctx) {
const changeRequest = ctx.subscription.getRequestForPlanChange(
'premium-plan',
1,
false // immediate change
)
expect(changeRequest.timeframe).to.equal('now')
expect(changeRequest.addOnUpdates).to.deep.equal([
new PaymentProviderSubscriptionAddOnUpdate({
code: ctx.addOn.code,
quantity: ctx.addOn.quantity,
unitPrice: ctx.addOn.unitPrice,
}),
])
})
it('stacks with pending add-on removal for term_end downgrade', function (ctx) {
const changeRequest = ctx.subscription.getRequestForPlanChange(
'cheap-plan',
1,
true // term_end change
)
expect(changeRequest.timeframe).to.equal('term_end')
// Should have NO add-ons because the pending change already removed them
expect(changeRequest.addOnUpdates).to.deep.equal([])
})
})
})
describe('getRequestForAddOnPurchase()', function () {
@@ -340,6 +434,51 @@ describe('PaymentProviderEntities', function () {
ctx.subscription.getRequestForAddOnPurchase(ctx.addOn.code)
).to.throw(Errors.DuplicateAddOnError)
})
describe('with pending plan downgrade', function () {
beforeEach(function (ctx) {
// Scenario: downgrade is scheduled, and then they want to buy an add-on immediately
const {
PaymentProviderSubscription,
PaymentProviderSubscriptionChange,
} = ctx.PaymentProviderEntities
ctx.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'premium-plan',
planName: 'Premium Plan',
planPrice: 20,
addOns: [],
subtotal: 20,
taxRate: 0.2,
taxAmount: 4,
total: 24,
currency: 'USD',
})
// Pending downgrade to cheaper plan at term_end
ctx.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscription,
nextPlanCode: 'cheap-plan',
nextPlanName: 'Cheap Plan',
nextPlanPrice: 5,
nextAddOns: [],
})
})
it('uses current add-ons for immediate add-on purchase', function (ctx) {
const changeRequest =
ctx.subscription.getRequestForAddOnPurchase('assistant')
expect(changeRequest.timeframe).to.equal('now')
// Should only have the new add-on, based on current state, client will reapply the pending change
expect(changeRequest.addOnUpdates).to.deep.equal([
new PaymentProviderSubscriptionAddOnUpdate({
code: 'assistant',
quantity: 1,
}),
])
})
})
})
describe('getRequestForAddOnUpdate()', function () {
@@ -410,6 +549,57 @@ describe('PaymentProviderEntities', function () {
ctx.subscription.getRequestForAddOnRemoval('another-add-on')
).to.throw(Errors.AddOnNotPresentError)
})
describe('with pending changes', function () {
beforeEach(function (ctx) {
// Scenario: a subscription has two add-ons, one already scheduled for removal
const {
PaymentProviderSubscription,
PaymentProviderSubscriptionAddOn,
} = ctx.PaymentProviderEntities
ctx.addOn1 = new PaymentProviderSubscriptionAddOn({
code: 'addon-1',
name: 'Add-On 1',
quantity: 1,
unitPrice: 2,
})
ctx.addOn2 = new PaymentProviderSubscriptionAddOn({
code: 'addon-2',
name: 'Add-On 2',
quantity: 1,
unitPrice: 3,
})
ctx.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
planName: 'My Plan',
planPrice: 10,
addOns: [ctx.addOn1, ctx.addOn2],
subtotal: 10.99,
taxRate: 0.2,
taxAmount: 2.4,
total: 14.4,
currency: 'USD',
})
// Set up a pending change that removes addon-1 at term_end
ctx.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscription,
nextPlanCode: ctx.subscription.planCode,
nextPlanName: ctx.subscription.planName,
nextPlanPrice: ctx.subscription.planPrice,
nextAddOns: [ctx.addOn2], // Only addon-2 remains
})
})
it('stacks multiple add-on removals at term_end', function (ctx) {
const changeRequest =
ctx.subscription.getRequestForAddOnRemoval('addon-2')
expect(changeRequest.timeframe).to.equal('term_end')
expect(changeRequest.addOnUpdates).to.deep.equal([]) // empty because both are scheduled for removal
})
})
})
describe('getRequestForAddOnReactivation()', function () {
@@ -471,6 +661,172 @@ describe('PaymentProviderEntities', function () {
})
})
describe('getRequestForPlanChangeCancellation()', function () {
it('returns null when there is no pending change', function (ctx) {
ctx.subscription.pendingChange = null
const result = ctx.subscription.getRequestForPlanChangeCancellation()
expect(result).to.be.null
})
it('returns null when pending change has no add-on changes', function (ctx) {
// Same add-ons in pending change as current subscription
ctx.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscription,
nextPlanCode: 'cheap-plan', // only plan change, no add-on change
nextPlanName: 'Cheap Plan',
nextPlanPrice: 5,
nextAddOns: [ctx.addOn], // same add-on as current subscription
})
const result = ctx.subscription.getRequestForPlanChangeCancellation()
expect(result).to.be.null
})
it('returns a change request preserving add-on removal when canceling plan change', function (ctx) {
// Pending change has both plan downgrade AND add-on removal
ctx.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscription,
nextPlanCode: 'cheap-plan',
nextPlanName: 'Cheap Plan',
nextPlanPrice: 5,
nextAddOns: [], // add-on is being removed
})
const result = ctx.subscription.getRequestForPlanChangeCancellation()
expect(result).to.not.be.null
expect(result.timeframe).to.equal('term_end')
expect(result.planCode).to.equal(ctx.subscription.planCode) // keep current plan
expect(result.addOnUpdates).to.deep.equal([]) // preserve add-on removal
})
it('returns a change request preserving add-on addition when canceling plan change', function (ctx) {
const {
PaymentProviderSubscriptionAddOn,
PaymentProviderSubscriptionAddOnUpdate,
} = ctx.PaymentProviderEntities
// Current subscription has one add-on
// Pending change has plan downgrade AND a new add-on
const newAddOn = new PaymentProviderSubscriptionAddOn({
code: 'new-addon',
name: 'New Add-On',
quantity: 1,
unitPrice: 3,
})
ctx.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscription,
nextPlanCode: 'cheap-plan',
nextPlanName: 'Cheap Plan',
nextPlanPrice: 5,
nextAddOns: [ctx.addOn, newAddOn], // current add-on plus new one
})
const result = ctx.subscription.getRequestForPlanChangeCancellation()
expect(result).to.not.be.null
expect(result.timeframe).to.equal('term_end')
expect(result.planCode).to.equal(ctx.subscription.planCode) // keep current plan
expect(result.addOnUpdates).to.deep.equal([
new PaymentProviderSubscriptionAddOnUpdate({
code: ctx.addOn.code,
quantity: ctx.addOn.quantity,
unitPrice: ctx.addOn.unitPrice,
}),
new PaymentProviderSubscriptionAddOnUpdate({
code: newAddOn.code,
quantity: newAddOn.quantity,
unitPrice: newAddOn.unitPrice,
}),
])
})
it('removes additional-license add-on when canceling pending group plan upgrade', function (ctx) {
const { MEMBERS_LIMIT_ADD_ON_CODE } = ctx.PaymentProviderEntities
// Current: professional-annual (no add-ons, not a group plan)
ctx.subscription.planCode = 'professional-annual'
ctx.subscription.planName = 'Professional Annual'
ctx.subscription.addOns = []
// Pending: group_professional_10_educational + 5 additional-license
const additionalLicenseAddOn =
new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
code: MEMBERS_LIMIT_ADD_ON_CODE,
name: 'Additional License',
quantity: 5,
unitPrice: 10,
})
ctx.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscription,
nextPlanCode: 'group_professional_10_educational',
nextPlanName: 'Group Professional Educational',
nextPlanPrice: 100,
nextAddOns: [additionalLicenseAddOn],
})
const result = ctx.subscription.getRequestForPlanChangeCancellation()
// Should return a change request that keeps the current plan
// but removes the additional-license add-on
expect(result).to.not.be.null
expect(result.timeframe).to.equal('term_end')
expect(result.planCode).to.equal('professional-annual')
expect(result.addOnUpdates).to.deep.equal([])
})
it('preserves non-members-limit add-ons when canceling pending group plan upgrade', function (ctx) {
const { MEMBERS_LIMIT_ADD_ON_CODE } = ctx.PaymentProviderEntities
// Current: professional-annual with AI add-on (not a group plan)
ctx.subscription.planCode = 'professional-annual'
ctx.subscription.planName = 'Professional Annual'
const aiAddOn =
new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
code: AI_ADD_ON_CODE,
name: 'AI Add-On',
quantity: 1,
unitPrice: 20,
})
ctx.subscription.addOns = [aiAddOn]
// Pending: group_professional_10_educational + AI add-on + 5 additional-license
const additionalLicenseAddOn =
new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
code: MEMBERS_LIMIT_ADD_ON_CODE,
name: 'Additional License',
quantity: 5,
unitPrice: 10,
})
ctx.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscription,
nextPlanCode: 'group_professional_10_educational',
nextPlanName: 'Group Professional Educational',
nextPlanPrice: 100,
nextAddOns: [aiAddOn, additionalLicenseAddOn],
})
const result = ctx.subscription.getRequestForPlanChangeCancellation()
// Should return a change request that keeps the current plan and AI add-on
// but removes the additional-license add-on
expect(result).to.not.be.null
expect(result.timeframe).to.equal('term_end')
expect(result.planCode).to.equal('professional-annual')
expect(result.addOnUpdates).to.deep.equal([
new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
unitPrice: 20,
}),
])
})
})
describe('with an add-on pending cancellation', function () {
beforeEach(function (ctx) {
ctx.subscription.pendingChange =
@@ -491,6 +847,7 @@ describe('PaymentProviderEntities', function () {
new PaymentProviderSubscriptionChangeRequest({
subscription: ctx.subscription,
timeframe: 'term_end',
planCode: ctx.subscription.pendingChange.nextPlanCode,
addOnUpdates: [ctx.addOn.toAddOnUpdate()],
})
)
@@ -5,8 +5,10 @@ import PaymentProviderEntities from '../../../../app/src/Features/Subscription/P
const {
PaymentProviderSubscription,
PaymentProviderSubscriptionChange,
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionUpdateRequest,
PaymentProviderSubscriptionAddOn,
PaymentProviderSubscriptionAddOnUpdate,
PaymentProviderAccount,
PaymentProviderCoupon,
@@ -459,6 +461,241 @@ describe('RecurlyClient', function () {
})
).to.be.rejectedWith(Error)
})
describe('when subscription has a pending add-on removal', function () {
beforeEach(function (ctx) {
// Create subscription with an add-on that has a pending removal scheduled
ctx.subscriptionWithAddOn = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
currency: 'EUR',
planCode: 'collaborator',
planName: 'Collaborator Plan',
planPrice: 13,
addOns: [
new PaymentProviderSubscriptionAddOn({
code: 'assistant',
name: 'AI Assistant',
quantity: 1,
unitPrice: 5,
}),
],
subtotal: 18,
taxRate: 0.1,
taxAmount: 1.8,
total: 19.8,
periodStart: new Date(),
periodEnd: new Date(),
collectionMethod: 'automatic',
service: 'recurly',
state: 'active',
})
ctx.subscriptionWithAddOn.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscriptionWithAddOn,
nextPlanCode: 'collaborator',
nextPlanName: 'Collaborator Plan',
nextPlanPrice: 13,
nextAddOns: [], // add-on is being removed
})
ctx.client.getSubscription = sinon.stub().resolves({
uuid: 'subscription-id',
account: { code: 'user-id' },
plan: { code: 'professional', name: 'Professional Plan' },
addOns: [
{
addOn: { code: 'assistant', name: 'AI Assistant' },
quantity: 1,
unitAmount: 5,
},
],
unitAmount: 20,
subtotal: 25,
taxInfo: { rate: 0.1 },
tax: 2.5,
total: 27.5,
currency: 'EUR',
currentPeriodStartedAt: new Date(),
currentPeriodEndsAt: new Date(),
collectionMethod: 'automatic',
netTerms: 0,
poNumber: '',
termsAndConditions: '',
})
})
it('re-applies pending add-on removal after immediate plan upgrade', async function (ctx) {
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
new PaymentProviderSubscriptionChangeRequest({
subscription: ctx.subscriptionWithAddOn,
timeframe: 'now',
planCode: 'professional',
})
)
// immediate change
expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
'uuid-subscription-id',
{ timeframe: 'now', planCode: 'professional' }
)
// re-apply pending change: only add-on removal, NOT the plan downgrade
// because the immediate change was a plan change, the user's new plan choice supersedes
expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
'uuid-subscription-id',
{
timeframe: 'term_end',
addOns: [],
}
)
expect(ctx.client.createSubscriptionChange.callCount).to.equal(2)
})
it('does not re-apply pending change if plan and add-ons are the same', async function (ctx) {
ctx.subscriptionWithAddOn.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: ctx.subscriptionWithAddOn,
nextPlanCode: 'professional', // pending change to professional
nextPlanName: 'Professional Plan',
nextPlanPrice: 20,
nextAddOns: [
new PaymentProviderSubscriptionAddOn({
code: 'assistant',
name: 'AI Assistant',
quantity: 1,
unitPrice: 5,
}),
],
})
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
new PaymentProviderSubscriptionChangeRequest({
subscription: ctx.subscriptionWithAddOn,
timeframe: 'now',
planCode: 'professional',
})
)
expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
})
it('does not re-apply anything if no pending change existed', async function (ctx) {
ctx.subscriptionWithAddOn.pendingChange = null
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
new PaymentProviderSubscriptionChangeRequest({
subscription: ctx.subscriptionWithAddOn,
timeframe: 'now',
planCode: 'professional',
})
)
expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
})
it('does not re-apply when timeframe is term_end', async function (ctx) {
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
new PaymentProviderSubscriptionChangeRequest({
subscription: ctx.subscriptionWithAddOn,
timeframe: 'term_end',
planCode: 'professional',
})
)
expect(ctx.client.createSubscriptionChange.callCount).to.equal(1)
})
it('re-applies pending plan downgrade while preserving newly purchased add-on', async function (ctx) {
const subscriptionWithPendingDowngrade =
new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
currency: 'EUR',
planCode: 'professional',
planName: 'Professional Plan',
planPrice: 20,
addOns: [], // NO add-ons originally
subtotal: 20,
taxRate: 0.1,
taxAmount: 2,
total: 22,
periodStart: new Date(),
periodEnd: new Date(),
collectionMethod: 'automatic',
service: 'recurly',
state: 'active',
})
subscriptionWithPendingDowngrade.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: subscriptionWithPendingDowngrade,
nextPlanCode: 'collaborator',
nextPlanName: 'Collaborator Plan',
nextPlanPrice: 13,
nextAddOns: [], // NO add-ons in pending change
})
// mock the subscription fetch after immediate update - now has add-on
ctx.client.getSubscription = sinon.stub().resolves({
uuid: 'subscription-id',
account: { code: 'user-id' },
plan: { code: 'professional', name: 'Professional Plan' },
addOns: [
{
addOn: { code: 'assistant', name: 'AI Assistant' },
quantity: 1,
unitAmount: 5,
},
],
unitAmount: 20,
subtotal: 25,
taxInfo: { rate: 0.1 },
tax: 2.5,
total: 27.5,
currency: 'EUR',
currentPeriodStartedAt: new Date(),
currentPeriodEndsAt: new Date(),
collectionMethod: 'automatic',
netTerms: 0,
poNumber: '',
termsAndConditions: '',
})
await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
new PaymentProviderSubscriptionChangeRequest({
subscription: subscriptionWithPendingDowngrade,
timeframe: 'now',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: 'assistant',
quantity: 1,
unitPrice: 5,
}),
],
})
)
expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
'uuid-subscription-id',
{
timeframe: 'now',
addOns: [{ code: 'assistant', quantity: 1, unitAmount: 5 }],
}
)
expect(ctx.client.createSubscriptionChange).to.have.been.calledWith(
'uuid-subscription-id',
{
timeframe: 'term_end',
planCode: 'collaborator',
addOns: [{ code: 'assistant', quantity: 1, unitAmount: 5 }], // add-on preserved!
}
)
expect(ctx.client.createSubscriptionChange.callCount).to.equal(2)
})
})
})
describe('updateSubscriptionDetails', function () {
@@ -1122,6 +1122,24 @@ describe('SubscriptionController', function () {
)
).to.be.true
})
it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
ctx.req.params.addOnCode = AI_ADD_ON_CODE
ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
new SubscriptionErrors.MultiplePendingChangesError()
)
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.res.status).toHaveBeenCalledWith(422)
expect(ctx.res.json).toHaveBeenCalledWith({
code: 'multiple_pending_changes',
message:
'Cannot complete purchase while there are multiple pending subscription changes. Please contact support.',
})
expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
.called
})
})
describe('removeAddon', function () {
@@ -1173,6 +1191,21 @@ describe('SubscriptionController', function () {
{ addon: AI_ADD_ON_CODE }
)
})
it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
ctx.SubscriptionHandler.promises.removeAddon.rejects(
new SubscriptionErrors.MultiplePendingChangesError()
)
await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.res.status).toHaveBeenCalledWith(422)
expect(ctx.res.json).toHaveBeenCalledWith({
code: 'multiple_pending_changes',
message:
'Cannot remove add-on while there are multiple pending subscription changes. Please contact support.',
})
})
})
describe('checkSubscriptionPauseStatus', function () {
@@ -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)
})
})
})
})
@@ -819,22 +819,6 @@ describe('SubscriptionGroupHandler', function () {
})
})
describe('ensureSubscriptionHasNoPendingChanges', function () {
it('should throw if the subscription has pending change', async function (ctx) {
await expect(
ctx.Handler.promises.ensureSubscriptionHasNoPendingChanges({
pendingChange: {},
})
).to.be.rejectedWith('This subscription has a pending change')
})
it('should not throw if the subscription has no pending change', async function (ctx) {
await expect(
ctx.Handler.promises.ensureSubscriptionHasNoPendingChanges({})
).to.not.be.rejected
})
})
describe('ensureSubscriptionHasNoPastDueInvoice', function () {
it('should throw if the subscription has past due invoice', async function (ctx) {
ctx.Modules.promises.hooks.fire
@@ -395,7 +395,7 @@ describe('SubscriptionHandler', function () {
})
})
it('should not fire cancelPendingPaidSubscriptionChange hook if user has no subscription', async function (ctx) {
it('should not fire hooks if user has no subscription', async function (ctx) {
ctx.LimitationsManager.promises.userHasSubscription.resolves({
hasSubscription: false,
subscription: null,
@@ -404,25 +404,91 @@ describe('SubscriptionHandler', function () {
ctx.user,
ctx.plan_code
)
expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
'cancelPendingPaidSubscriptionChange',
sinon.match.any
)
expect(ctx.Modules.promises.hooks.fire).to.not.have.been.called
})
it('should fire cancelPendingPaidSubscriptionChange to update a valid subscription', async function (ctx) {
it('should get payment record and apply change request', async function (ctx) {
const changeRequest = { subscription: { id: 'sub_123' } }
const paymentProviderSubscription = {
id: 'sub_123',
service: 'stripe',
getRequestForPlanChangeCancellation: sinon
.stub()
.returns(changeRequest),
}
const paymentRecord = { subscription: paymentProviderSubscription }
ctx.LimitationsManager.promises.userHasSubscription.resolves({
hasSubscription: true,
subscription: ctx.subscription,
})
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord', ctx.subscription)
.resolves([paymentRecord])
ctx.Modules.promises.hooks.fire
.withArgs(
'applySubscriptionChangeRequestAndSync',
changeRequest,
ctx.user._id.toString()
)
.resolves([Promise.resolve()])
await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
ctx.user,
ctx.plan_code
)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'getPaymentFromRecord',
ctx.subscription
)
expect(paymentProviderSubscription.getRequestForPlanChangeCancellation).to
.have.been.called
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'applySubscriptionChangeRequestAndSync',
changeRequest,
ctx.user._id.toString()
)
})
it('should remove pending change when there are no add-on changes to preserve', async function (ctx) {
const paymentProviderSubscription = {
id: 'sub_123',
service: 'stripe',
pendingChange: { nextPlanCode: 'student' },
getRequestForPlanChangeCancellation: sinon.stub().returns(null),
}
const paymentRecord = { subscription: paymentProviderSubscription }
ctx.LimitationsManager.promises.userHasSubscription.resolves({
hasSubscription: true,
subscription: ctx.subscription,
})
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord', ctx.subscription)
.resolves([paymentRecord])
ctx.Modules.promises.hooks.fire
.withArgs('cancelPendingPaidSubscriptionChange', ctx.subscription)
.resolves([Promise.resolve()])
await ctx.SubscriptionHandler.promises.cancelPendingSubscriptionChange(
ctx.user,
ctx.plan_code
)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'getPaymentFromRecord',
ctx.subscription
)
expect(paymentProviderSubscription.getRequestForPlanChangeCancellation).to
.have.been.called
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'cancelPendingPaidSubscriptionChange',
ctx.subscription
)
expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
'applySubscriptionChangeRequestAndSync'
)
})
})
@@ -378,7 +378,7 @@ describe('UserMembershipController', () => {
})
})
it('should be false when recurly subscription has pending changes', async ({
it('should be true when recurly subscription has pending changes', async ({
UserMembershipController,
req,
RecurlyClient,
@@ -390,7 +390,7 @@ describe('UserMembershipController', () => {
})
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewParams.canUseAddSeatsFeature).to.equal(false)
expect(viewParams.canUseAddSeatsFeature).to.equal(true)
},
})
})