Просмотр исходного кода

[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
Kristina 6 месяцев назад
Родитель
Сommit
4c5cdecffa
19 измененных файлов с 1178 добавлено и 128 удалено
  1. 2 2
      services/web/app/src/Features/Subscription/Errors.mjs
  2. 120 30
      services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs
  3. 132 0
      services/web/app/src/Features/Subscription/RecurlyClient.mjs
  4. 59 9
      services/web/app/src/Features/Subscription/SubscriptionController.mjs
  5. 10 8
      services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
  6. 0 16
      services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs
  7. 22 2
      services/web/app/src/Features/Subscription/SubscriptionHandler.mjs
  8. 1 4
      services/web/app/src/Features/UserMembership/UserMembershipController.mjs
  9. 1 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  10. 10 8
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx
  11. 12 0
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx
  12. 57 0
      services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
  13. 361 4
      services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs
  14. 237 0
      services/web/test/unit/src/Subscription/RecurlyClient.test.mjs
  15. 33 0
      services/web/test/unit/src/Subscription/SubscriptionController.test.mjs
  16. 48 21
      services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs
  17. 0 16
      services/web/test/unit/src/Subscription/SubscriptionGroupHandler.test.mjs
  18. 71 5
      services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs
  19. 2 2
      services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs

+ 2 - 2
services/web/app/src/Features/Subscription/Errors.mjs

@@ -21,7 +21,7 @@ export class MissingBillingInfoError extends OError {}
 
 export class ManuallyCollectedError extends OError {}
 
-export class PendingChangeError extends OError {}
+export class MultiplePendingChangesError extends OError {}
 
 export class InactiveError extends OError {}
 
@@ -76,7 +76,7 @@ export default {
   PaymentFailedError,
   MissingBillingInfoError,
   ManuallyCollectedError,
-  PendingChangeError,
+  MultiplePendingChangesError,
   InactiveError,
   SubtotalLimitExceededError,
   HasPastDueInvoiceError,

+ 120 - 30
services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs

@@ -129,6 +129,26 @@ export class PaymentProviderSubscription {
     }
   }
 
+  /**
+   * Returns the add-ons that should be used as the base for a change request.
+   *
+   * For immediate changes (timeframe = 'now'), we use the current add-ons because
+   * the user is still paying for them until term_end. Any pending term_end changes
+   * will need to be re-applied after the immediate change by the payment provider client.
+   *
+   * For term_end changes, if there's already a pending change, we use its nextAddOns
+   * as the base to merge scheduled changes together.
+   *
+   * @param {"now" | "term_end"} timeframe - when the change will be applied
+   * @return {PaymentProviderSubscriptionAddOn[]}
+   */
+  #getBaseAddOnsForChangeRequest(timeframe) {
+    if (timeframe === 'term_end' && this.pendingChange != null) {
+      return this.pendingChange.nextAddOns
+    }
+    return this.addOns
+  }
+
   /**
    * Change this subscription's plan
    * @param {string} planCode - the new plan code
@@ -137,41 +157,47 @@ export class PaymentProviderSubscription {
    * @return {PaymentProviderSubscriptionChangeRequest}
    */
   getRequestForPlanChange(planCode, quantity, shouldChangeAtTermEnd) {
-    const changeRequest = new PaymentProviderSubscriptionChangeRequest({
-      subscription: this,
-      timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
-      planCode,
-    })
+    const timeframe = shouldChangeAtTermEnd ? 'term_end' : 'now'
+    const baseAddOns = this.#getBaseAddOnsForChangeRequest(timeframe)
+
+    // Start with all existing add-ons, but filter out additional-license
+    // since we'll handle it specially for group plans below
+    const addOnUpdates = baseAddOns
+      .filter(addOn => addOn.code !== MEMBERS_LIMIT_ADD_ON_CODE)
+      .map(addOn => addOn.toAddOnUpdate())
 
     if (quantity !== 1) {
       // Only group plans in Stripe can have larger than 1 quantity
-      // This is because in Stripe, the group plans are configued with per-seat pricing
+      // This is because in Stripe, the group plans are configured with per-seat pricing
       // and the quantity is the number of seats
       // Setting the members limit add-on quantity accordingly
-      // so it is compitible with Recurly's group plan model (1 base plan + add-on for each member)
-      changeRequest.addOnUpdates = [
+      // so it is compatible with Recurly's group plan model (1 base plan + add-on for each member)
+      addOnUpdates.push(
         new PaymentProviderSubscriptionAddOnUpdate({
           code: MEMBERS_LIMIT_ADD_ON_CODE,
           quantity,
-        }),
-      ]
+        })
+      )
     }
 
-    // Carry the AI add-on to the new plan if applicable
-    if (
-      this.isStandaloneAiAddOn() ||
-      (!shouldChangeAtTermEnd && this.hasAddOn(AI_ADD_ON_CODE)) ||
-      (shouldChangeAtTermEnd && this.hasAddOnNextPeriod(AI_ADD_ON_CODE))
-    ) {
-      const addOnUpdate = new PaymentProviderSubscriptionAddOnUpdate({
-        code: AI_ADD_ON_CODE,
-        quantity: 1,
-      })
-      changeRequest.addOnUpdates = changeRequest.addOnUpdates
-        ? [...changeRequest.addOnUpdates, addOnUpdate]
-        : [addOnUpdate]
+    // When upgrading from standalone AI plan, add the AI add-on since
+    // the standalone plan doesn't have it as an add-on
+    if (this.isStandaloneAiAddOn() && !isStandaloneAiAddOnPlanCode(planCode)) {
+      addOnUpdates.push(
+        new PaymentProviderSubscriptionAddOnUpdate({
+          code: AI_ADD_ON_CODE,
+          quantity: 1,
+        })
+      )
     }
 
+    const changeRequest = new PaymentProviderSubscriptionChangeRequest({
+      subscription: this,
+      timeframe,
+      planCode,
+      addOnUpdates,
+    })
+
     return changeRequest
   }
 
@@ -194,7 +220,8 @@ export class PaymentProviderSubscription {
       })
     }
 
-    const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
+    const baseAddOns = this.#getBaseAddOnsForChangeRequest('now')
+    const addOnUpdates = baseAddOns.map(addOn => addOn.toAddOnUpdate())
     addOnUpdates.push(
       new PaymentProviderSubscriptionAddOnUpdate({ code, quantity, unitPrice })
     )
@@ -226,7 +253,8 @@ export class PaymentProviderSubscription {
       )
     }
 
-    const addOnUpdates = this.addOns.map(addOn => {
+    const baseAddOns = this.#getBaseAddOnsForChangeRequest('now')
+    const addOnUpdates = baseAddOns.map(addOn => {
       const update = addOn.toAddOnUpdate()
 
       if (update.code === code) {
@@ -261,13 +289,22 @@ export class PaymentProviderSubscription {
         }
       )
     }
-    const addOnUpdates = this.addOns
+    const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd)
+    const timeframe = isInTrial ? 'now' : 'term_end'
+
+    const baseAddOns = this.#getBaseAddOnsForChangeRequest(timeframe)
+    const addOnUpdates = baseAddOns
       .filter(addOn => addOn.code !== code)
       .map(addOn => addOn.toAddOnUpdate())
-    const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd)
+
+    // preserve pending plan change when scheduling add-on removal at term_end
+    const planCode =
+      timeframe === 'term_end' ? this.pendingChange?.nextPlanCode : undefined
+
     return new PaymentProviderSubscriptionChangeRequest({
       subscription: this,
-      timeframe: isInTrial ? 'now' : 'term_end',
+      timeframe,
+      planCode,
       addOnUpdates,
     })
   }
@@ -295,9 +332,62 @@ export class PaymentProviderSubscription {
       .map(addOn => addOn.toAddOnUpdate())
     addOnUpdates.push(reactivatedAddOn.toAddOnUpdate())
 
+    // preserve pending plan change when reactivating add-on
+    const planCode = pendingChange.nextPlanCode
+
+    return new PaymentProviderSubscriptionChangeRequest({
+      subscription: this,
+      timeframe: 'term_end',
+      planCode,
+      addOnUpdates,
+    })
+  }
+
+  /**
+   * Cancel only the pending plan change while preserving any pending add-on changes
+   *
+   * This is used when a user wants to "keep their current plan" but has also
+   * scheduled add-on changes (additions or removals) that should still happen.
+   *
+   * @return {PaymentProviderSubscriptionChangeRequest | null}
+   */
+  getRequestForPlanChangeCancellation() {
+    const pendingChange = this.pendingChange
+    if (pendingChange == null) {
+      return null
+    }
+
+    const currentAddOnCodes = this.addOns.map(a => a.code)
+    const pendingAddOnCodes = pendingChange.nextAddOns.map(a => a.code)
+
+    const hasAddOnChanges =
+      currentAddOnCodes.length !== pendingAddOnCodes.length ||
+      !currentAddOnCodes.every(code => pendingAddOnCodes.includes(code))
+
+    if (!hasAddOnChanges) {
+      return null
+    }
+
+    const addOnUpdates = pendingChange.nextAddOns
+      .filter(addOn => {
+        if (
+          addOn.code === MEMBERS_LIMIT_ADD_ON_CODE &&
+          !this.isGroupSubscription() &&
+          PlansLocator.isGroupPlanCode(pendingChange.nextPlanCode)
+        ) {
+          // if there was a pending group plan upgrade, removing the plan change
+          // means we also need to remove the members limit add-on
+          return false
+        }
+
+        return true
+      })
+      .map(addOn => addOn.toAddOnUpdate())
+
     return new PaymentProviderSubscriptionChangeRequest({
       subscription: this,
       timeframe: 'term_end',
+      planCode: this.planCode,
       addOnUpdates,
     })
   }
@@ -346,8 +436,8 @@ export class PaymentProviderSubscription {
    * @return {PaymentProviderSubscriptionChangeRequest}
    */
   getRequestForGroupPlanUpgrade(newPlanCode) {
-    // Ensure all the existing add-ons are added to the new plan
-    const addOns = this.addOns.map(
+    const baseAddOns = this.#getBaseAddOnsForChangeRequest('now')
+    const addOns = baseAddOns.map(
       addOn =>
         new PaymentProviderSubscriptionAddOnUpdate({
           code: addOn.code,

+ 132 - 0
services/web/app/src/Features/Subscription/RecurlyClient.mjs

@@ -25,6 +25,7 @@ import {
 } from './Errors.mjs'
 import RecurlyMetrics from './RecurlyMetrics.mjs'
 import { isStandaloneAiAddOnPlanCode, AI_ADD_ON_CODE } from './AiHelper.mjs'
+import _ from 'lodash'
 
 /**
  * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities.mjs'
@@ -240,6 +241,24 @@ async function updateSubscriptionDetails(updateRequest) {
 async function applySubscriptionChangeRequest(changeRequest) {
   const body = subscriptionChangeRequestToApi(changeRequest)
 
+  // capture pending change before immediate update, as it will be removed by Recurly
+  const pendingChange =
+    changeRequest.timeframe === 'now'
+      ? changeRequest.subscription.pendingChange
+      : null
+
+  if (pendingChange != null) {
+    logger.warn(
+      {
+        subscriptionId: changeRequest.subscription.id,
+        timeframe: changeRequest.timeframe,
+        pendingPlanCode: pendingChange.nextPlanCode,
+        pendingAddOnCodes: pendingChange.nextAddOns.map(a => a.code),
+      },
+      'Applying immediate change to subscription with pending term_end change - will attempt to re-apply'
+    )
+  }
+
   try {
     const change = await client.createSubscriptionChange(
       `uuid-${changeRequest.subscription.id}`,
@@ -249,6 +268,13 @@ async function applySubscriptionChangeRequest(changeRequest) {
       { subscriptionId: changeRequest.subscription.id, changeId: change.id },
       'created subscription change'
     )
+
+    if (pendingChange != null) {
+      await _reapplyPendingChangeAfterImmediateUpdate(
+        changeRequest.subscription.id,
+        pendingChange
+      )
+    }
   } catch (err) {
     if (err instanceof recurly.errors.ValidationError) {
       /**
@@ -272,6 +298,112 @@ async function applySubscriptionChangeRequest(changeRequest) {
   }
 }
 
+/**
+ * Re-apply a pending term_end change after an immediate update.
+ *
+ * When an immediate change is applied to a subscription that has a pending
+ * term_end change (e.g., scheduled add-on removal or plan downgrade), we need
+ * to re-create the pending change with the correct items based on what the
+ * pending change was trying to achieve and what happened in the immediate change.
+ *
+ * @param {string} subscriptionId
+ * @param {PaymentProviderSubscriptionChange} pendingChange
+ */
+async function _reapplyPendingChangeAfterImmediateUpdate(
+  subscriptionId,
+  pendingChange
+) {
+  const updatedSubscription = await getSubscription(subscriptionId)
+
+  const planCodeDiffers =
+    pendingChange.nextPlanCode !== updatedSubscription.planCode
+
+  const immediateChangeIncludedPlanChange =
+    pendingChange.subscription.planCode !== updatedSubscription.planCode
+
+  // Build add-on objects for comparison: { code: { quantity, unitPrice } }
+  const preUpdateAddOns = pendingChange.subscription.addOns.reduce(
+    (acc, addOn) => ({
+      ...acc,
+      [addOn.code]: { quantity: addOn.quantity, unitPrice: addOn.unitPrice },
+    }),
+    {}
+  )
+  const preUpdatePendingAddOns = pendingChange.nextAddOns.reduce(
+    (acc, addOn) => ({
+      ...acc,
+      [addOn.code]: { quantity: addOn.quantity, unitPrice: addOn.unitPrice },
+    }),
+    {}
+  )
+  const postUpdateAddOns = updatedSubscription.addOns.reduce(
+    (acc, addOn) => ({
+      ...acc,
+      [addOn.code]: { quantity: addOn.quantity, unitPrice: addOn.unitPrice },
+    }),
+    {}
+  )
+
+  // Merge: start with pending add-ons, add any new add-ons from immediate update
+  const mergedAddOns = { ...preUpdatePendingAddOns }
+  for (const [code, details] of Object.entries(postUpdateAddOns)) {
+    // include any add-ons that were added via immediate update just now
+    if (!(code in preUpdateAddOns) && !(code in mergedAddOns)) {
+      mergedAddOns[code] = details
+    }
+  }
+
+  const addOnsDiffer = !_.isEqual(mergedAddOns, postUpdateAddOns)
+
+  if (!planCodeDiffers && !addOnsDiffer) {
+    logger.debug(
+      { subscriptionId },
+      'No pending changes to re-apply after immediate update'
+    )
+    return
+  }
+
+  // only re-apply the pending plan change if:
+  // 1. immediate change didn't include a plan change (was add-on only)
+  // 2. pending plan actually differs from the current plan
+  // If the immediate change was a plan change, the user's new plan choice supersedes the old pending plan.
+  const shouldReapplyPlanCode =
+    !immediateChangeIncludedPlanChange && planCodeDiffers
+
+  logger.debug(
+    {
+      subscriptionId,
+      shouldReapplyPlanCode,
+      shouldReapplyAddOns: addOnsDiffer,
+    },
+    're-applying pending term_end change after immediate update'
+  )
+
+  /** @type {recurly.SubscriptionChangeCreate} */
+  const requestBody = {
+    timeframe: 'term_end',
+    addOns: Object.entries(mergedAddOns).map(([code, details]) => ({
+      code,
+      quantity: details.quantity,
+      unitAmount: details.unitPrice,
+    })),
+  }
+
+  if (shouldReapplyPlanCode) {
+    requestBody.planCode = pendingChange.nextPlanCode
+  }
+
+  const change = await client.createSubscriptionChange(
+    `uuid-${subscriptionId}`,
+    requestBody
+  )
+
+  logger.debug(
+    { subscriptionId, changeId: change.id },
+    're-applied pending term_end change'
+  )
+}
+
 /**
  * Preview a subscription change
  *

+ 59 - 9
services/web/app/src/Features/Subscription/SubscriptionController.mjs

@@ -35,6 +35,7 @@ import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUse
 import { z, parseReq } from '../../infrastructure/Validation.mjs'
 import { IndeterminateInvoiceError } from '../Errors/Errors.js'
 import SubscriptionLocator from './SubscriptionLocator.mjs'
+import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
 
 const {
   DuplicateAddOnError,
@@ -42,6 +43,7 @@ const {
   PaymentActionRequiredError,
   PaymentFailedError,
   MissingBillingInfoError,
+  MultiplePendingChangesError,
 } = Errors
 
 const SUBSCRIPTION_PAUSED_REDIRECT_PATH =
@@ -157,7 +159,6 @@ async function checkSubscriptionPauseStatus(user) {
 /**
  * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
  * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
- * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
  * @import { PaymentMethod } from './types'
  */
 
@@ -655,6 +656,16 @@ async function purchaseAddon(req, res, next) {
         reason: err.info.reason,
         adviceCode: err.info.adviceCode,
       })
+    } else if (err instanceof MultiplePendingChangesError) {
+      logger.warn(
+        { userId: user._id, err, addOnCode },
+        'Cannot purchase add-on: multiple pending changes'
+      )
+      return res.status(422).json({
+        code: 'multiple_pending_changes',
+        message:
+          'Cannot complete purchase while there are multiple pending subscription changes. Please contact support.',
+      })
     } else {
       if (err instanceof Error) {
         OError.tag(err, 'something went wrong purchasing add-ons', {
@@ -703,6 +714,16 @@ async function removeAddon(req, res, next) {
         'Your subscription does not contain the requested add-on',
         { addon: addOnCode }
       )
+    } else if (err instanceof MultiplePendingChangesError) {
+      logger.warn(
+        { userId: user._id, err, addOnCode },
+        'Cannot remove add-on: multiple pending changes'
+      )
+      return res.status(422).json({
+        code: 'multiple_pending_changes',
+        message:
+          'Cannot remove add-on while there are multiple pending subscription changes. Please contact support.',
+      })
     } else {
       if (err instanceof Error) {
         OError.tag(err, 'something went wrong removing add-ons', {
@@ -1104,9 +1125,38 @@ function makeChangePreview(
   paymentMethod
 ) {
   const subscription = subscriptionChange.subscription
+
+  // For the future invoice display, if there's a pending change scheduled,
+  // we should show what will happen at renewal (the pending change state)
+  // merged with any new changes from this immediate update
+  const pendingChange = subscription.pendingChange
+
+  let futureInvoiceChange
+  if (pendingChange) {
+    const pendingAddOnCodes = new Set(pendingChange.nextAddOns.map(a => a.code))
+    const mergedAddOns = [...pendingChange.nextAddOns]
+
+    for (const addOn of subscriptionChange.nextAddOns) {
+      if (!pendingAddOnCodes.has(addOn.code)) {
+        mergedAddOns.push(addOn)
+      }
+    }
+
+    futureInvoiceChange = new PaymentProviderSubscriptionChange({
+      subscription,
+      nextPlanCode: pendingChange.nextPlanCode,
+      nextPlanName: pendingChange.nextPlanName,
+      nextPlanPrice: pendingChange.nextPlanPrice,
+      nextAddOns: mergedAddOns,
+    })
+  } else {
+    futureInvoiceChange = subscriptionChange
+  }
+
   const nextPlan = PlansLocator.findLocalPlanInSettings(
-    subscriptionChange.nextPlanCode
+    futureInvoiceChange.nextPlanCode
   )
+
   return {
     change: subscriptionChangeDescription,
     currency: subscription.currency,
@@ -1120,24 +1170,24 @@ function makeChangePreview(
       date: subscription.periodEnd.toISOString(),
       plan: {
         name: getPlanNameForDisplay(
-          subscriptionChange.nextPlanName,
-          subscriptionChange.nextPlanCode
+          futureInvoiceChange.nextPlanName,
+          futureInvoiceChange.nextPlanCode
         ),
-        amount: subscriptionChange.nextPlanPrice,
+        amount: futureInvoiceChange.nextPlanPrice,
       },
-      addOns: subscriptionChange.nextAddOns.map(addOn => ({
+      addOns: futureInvoiceChange.nextAddOns.map(addOn => ({
         code: addOn.code,
         name: addOn.name,
         quantity: addOn.quantity,
         unitAmount: addOn.unitPrice,
         amount: addOn.preTaxTotal,
       })),
-      subtotal: subscriptionChange.subtotal,
+      subtotal: futureInvoiceChange.subtotal,
       tax: {
         rate: subscription.taxRate,
-        amount: subscriptionChange.tax,
+        amount: futureInvoiceChange.tax,
       },
-      total: subscriptionChange.total,
+      total: futureInvoiceChange.total,
     },
   }
 }

+ 10 - 8
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -14,12 +14,12 @@ import { isProfessionalGroupPlan } from './PlansHelper.mjs'
 import {
   MissingBillingInfoError,
   ManuallyCollectedError,
-  PendingChangeError,
   InactiveError,
   SubtotalLimitExceededError,
   HasPastDueInvoiceError,
   HasNoAdditionalLicenseWhenManuallyCollectedError,
   PaymentActionRequiredError,
+  MultiplePendingChangesError,
 } from './Errors.mjs'
 
 const MAX_NUMBER_OF_USERS = 20
@@ -146,9 +146,6 @@ async function addSeatsToGroupSubscription(req, res) {
         userId
       )
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
-    await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
-      paymentProviderSubscription
-    )
     await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
       subscription
     )
@@ -186,7 +183,6 @@ async function addSeatsToGroupSubscription(req, res) {
     }
 
     if (
-      error instanceof PendingChangeError ||
       error instanceof InactiveError ||
       error instanceof HasPastDueInvoiceError
     ) {
@@ -228,7 +224,6 @@ async function previewAddSeatsSubscriptionChange(req, res) {
   } catch (error) {
     if (
       error instanceof MissingBillingInfoError ||
-      error instanceof PendingChangeError ||
       error instanceof InactiveError ||
       error instanceof HasPastDueInvoiceError ||
       error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError
@@ -271,7 +266,7 @@ async function createAddSeatsSubscriptionChange(req, res) {
   } catch (error) {
     if (
       error instanceof MissingBillingInfoError ||
-      error instanceof PendingChangeError ||
+      error instanceof MultiplePendingChangesError ||
       error instanceof InactiveError ||
       error instanceof HasPastDueInvoiceError ||
       error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError
@@ -383,7 +378,7 @@ async function subscriptionUpgradePage(req, res) {
       return res.redirect('/user/subscription/group/subtotal-limit-exceeded')
     }
 
-    if (error instanceof PendingChangeError || error instanceof InactiveError) {
+    if (error instanceof InactiveError) {
       return res.redirect('/user/subscription')
     }
 
@@ -406,6 +401,13 @@ async function upgradeSubscription(req, res) {
         publicKey: error.info.publicKey,
       })
     }
+    if (error instanceof MultiplePendingChangesError) {
+      return res.status(422).json({
+        code: 'multiple_pending_changes',
+        message:
+          'Cannot upgrade subscription while there are multiple pending subscription changes. Please contact support.',
+      })
+    }
     logger.err({ error }, 'error trying to upgrade subscription')
     return res.sendStatus(500)
   }

+ 0 - 16
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs

@@ -14,7 +14,6 @@ import Modules from '../../infrastructure/Modules.mjs'
 import PaymentProviderEntities from './PaymentProviderEntities.mjs'
 import {
   ManuallyCollectedError,
-  PendingChangeError,
   InactiveError,
   HasPastDueInvoiceError,
   HasNoAdditionalLicenseWhenManuallyCollectedError,
@@ -100,16 +99,6 @@ async function ensureSubscriptionCollectionMethodIsNotManual(
   }
 }
 
-async function ensureSubscriptionHasNoPendingChanges(
-  paymentProviderSubscription
-) {
-  if (paymentProviderSubscription.pendingChange) {
-    throw new PendingChangeError('This subscription has a pending change', {
-      subscription_id: paymentProviderSubscription.id,
-    })
-  }
-}
-
 async function ensureSubscriptionHasNoPastDueInvoice(subscription) {
   const [paymentRecord] = await Modules.promises.hooks.fire(
     'getPaymentFromRecord',
@@ -184,7 +173,6 @@ async function _addSeatsSubscriptionChange(userId, adding) {
     await getUsersGroupSubscriptionDetails(userId)
   await ensureFlexibleLicensingEnabled(plan)
   await ensureSubscriptionIsActive(subscription)
-  await ensureSubscriptionHasNoPendingChanges(paymentProviderSubscription)
   await checkBillingInfoExistence(paymentProviderSubscription, userId)
   await ensureSubscriptionHasNoPastDueInvoice(subscription)
 
@@ -481,9 +469,6 @@ export default {
   ensureSubscriptionCollectionMethodIsNotManual: callbackify(
     ensureSubscriptionCollectionMethodIsNotManual
   ),
-  ensureSubscriptionHasNoPendingChanges: callbackify(
-    ensureSubscriptionHasNoPendingChanges
-  ),
   ensureSubscriptionHasNoPastDueInvoice: callbackify(
     ensureSubscriptionHasNoPastDueInvoice
   ),
@@ -503,7 +488,6 @@ export default {
     ensureFlexibleLicensingEnabled,
     ensureSubscriptionIsActive,
     ensureSubscriptionCollectionMethodIsNotManual,
-    ensureSubscriptionHasNoPendingChanges,
     ensureSubscriptionHasNoPastDueInvoice,
     ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual,
     getTotalConfirmedUsersInGroup,

+ 22 - 2
services/web/app/src/Features/Subscription/SubscriptionHandler.mjs

@@ -126,10 +126,30 @@ async function cancelPendingSubscriptionChange(user) {
     await LimitationsManager.promises.userHasSubscription(user)
 
   if (hasSubscription && subscription != null) {
-    await Modules.promises.hooks.fire(
-      'cancelPendingPaidSubscriptionChange',
+    const [paymentRecord] = await Modules.promises.hooks.fire(
+      'getPaymentFromRecord',
       subscription
     )
+
+    if (paymentRecord != null) {
+      const changeRequest =
+        paymentRecord.subscription.getRequestForPlanChangeCancellation()
+
+      if (changeRequest) {
+        // There are pending add-on changes to preserve, apply the change request
+        await Modules.promises.hooks.fire(
+          'applySubscriptionChangeRequestAndSync',
+          changeRequest,
+          user._id.toString()
+        )
+      } else if (paymentRecord.subscription.pendingChange != null) {
+        // No add-on changes to preserve, just remove the pending change
+        await Modules.promises.hooks.fire(
+          'cancelPendingPaidSubscriptionChange',
+          subscription
+        )
+      }
+    }
   }
 }
 

+ 1 - 4
services/web/app/src/Features/UserMembership/UserMembershipController.mjs

@@ -58,10 +58,7 @@ async function manageGroupMembers(req, res, next) {
   }
 
   const canUseAddSeatsFeature = Boolean(
-    plan?.canUseFlexibleLicensing &&
-    isAdmin &&
-    recurlySubscription &&
-    !recurlySubscription.pendingChange
+    plan?.canUseFlexibleLicensing && isAdmin && recurlySubscription
   )
 
   res.render('user_membership/group-members-react', {

+ 1 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx

@@ -368,7 +368,7 @@ function FlexibleGroupLicensingActions({
 }) {
   const { t } = useTranslation()
 
-  if (subscription.pendingPlan || subscription.payment.hasPastDueInvoice) {
+  if (subscription.payment.hasPastDueInvoice) {
     return null
   }
 

+ 10 - 8
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx

@@ -38,22 +38,24 @@ function KeepCurrentPlanButton() {
 function ChangePlanButton({ plan }: { plan: Plan }) {
   const { t } = useTranslation()
   const { personalSubscription } = useSubscriptionDashboardContext()
+  const currentPlanCode = personalSubscription?.planCode?.split('_')[0]
+  const pendingPlanCode =
+    personalSubscription?.pendingPlan?.planCode?.split('_')[0]
   const isCurrentPlanForUser =
-    personalSubscription?.planCode &&
-    plan.planCode === personalSubscription.planCode.split('_')[0]
+    currentPlanCode && plan.planCode === currentPlanCode
+
+  if (isCurrentPlanForUser) {
+    if (pendingPlanCode && pendingPlanCode !== currentPlanCode) {
+      return <KeepCurrentPlanButton />
+    }
 
-  if (isCurrentPlanForUser && personalSubscription.pendingPlan) {
-    return <KeepCurrentPlanButton />
-  } else if (isCurrentPlanForUser && !personalSubscription.pendingPlan) {
     return (
       <b className="d-inline-flex align-items-center">
         <MaterialIcon type="check" />
         &nbsp;{t('your_plan')}
       </b>
     )
-  } else if (
-    personalSubscription?.pendingPlan?.planCode?.split('_')[0] === plan.planCode
-  ) {
+  } else if (pendingPlanCode === plan.planCode) {
     return (
       <b className="d-inline-flex align-items-center">
         <MaterialIcon type="check" />

+ 12 - 0
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

@@ -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} />,

+ 57 - 0
services/web/test/frontend/features/subscription/fixtures/subscriptions.ts

@@ -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: [],

+ 361 - 4
services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs

@@ -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()],
               })
             )

+ 237 - 0
services/web/test/unit/src/Subscription/RecurlyClient.test.mjs

@@ -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 () {

+ 33 - 0
services/web/test/unit/src/Subscription/SubscriptionController.test.mjs

@@ -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 () {

+ 48 - 21
services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs

@@ -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)
+      })
+    })
   })
 })

+ 0 - 16
services/web/test/unit/src/Subscription/SubscriptionGroupHandler.test.mjs

@@ -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

+ 71 - 5
services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs

@@ -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 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 fire cancelPendingPaidSubscriptionChange to update a valid subscription', async function (ctx) {
+    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'
+      )
     })
   })
 

+ 2 - 2
services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs

@@ -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)
           },
         })
       })