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