diff --git a/services/web/app/src/Features/Subscription/Errors.mjs b/services/web/app/src/Features/Subscription/Errors.mjs index 546f5822e5..aac4b94fa7 100644 --- a/services/web/app/src/Features/Subscription/Errors.mjs +++ b/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, diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs b/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs index 2858f593fc..da7057ad39 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs +++ b/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, diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.mjs b/services/web/app/src/Features/Subscription/RecurlyClient.mjs index cfd4c9443a..5b50be27a4 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.mjs +++ b/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 * diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.mjs b/services/web/app/src/Features/Subscription/SubscriptionController.mjs index cd1951eb12..2f4fa3a9ec 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.mjs +++ b/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, }, } } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index d52a8df6ec..7fcd3bbd6f 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/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) } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs index ff584fee1c..e2299c1ce8 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs +++ b/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, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs b/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs index 19f9f658c4..1e5fcde5c7 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs +++ b/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 + ) + } + } } } diff --git a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs b/services/web/app/src/Features/UserMembership/UserMembershipController.mjs index c204fee93b..01b93f16e3 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs +++ b/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', { diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx index c4b5acb5ff..6cc3c7f935 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx +++ b/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 } diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx index 330b68dc82..2be2f9c431 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/individual-plans-table.tsx +++ b/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 + } - if (isCurrentPlanForUser && personalSubscription.pendingPlan) { - return - } else if (isCurrentPlanForUser && !personalSubscription.pendingPlan) { return (  {t('your_plan')} ) - } else if ( - personalSubscription?.pendingPlan?.planCode?.split('_')[0] === plan.planCode - ) { + } else if (pendingPlanCode === plan.planCode) { return ( diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx index bbcb955d2d..4f029a3ae8 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx +++ b/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('', 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( , diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts index 56c37172dc..9f6795ee71 100644 --- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts +++ b/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: [], diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs b/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs index 404426e764..ef13aab412 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs +++ b/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()], }) ) diff --git a/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs b/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs index ec538a8c0f..121404a918 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs +++ b/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 () { diff --git a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs index f515c068fc..ecd56ff630 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs +++ b/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 () { diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs index 406195ab69..26d2a12e98 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs +++ b/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) + }) + }) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandler.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupHandler.test.mjs index 7ea2548db6..ce068e2da3 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandler.test.mjs +++ b/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 diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs index 1ec71d8d4c..82d8e5c660 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs +++ b/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 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' + ) }) }) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index 146267dc14..d9ce158cbc 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/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) }, }) })