[web] Cleanup plans-2026-phase-1 - phase 4: rest of backend (#34771)

* Remove `plans-2026-phase-1` from subscription module

* Remove code related to `plans-2026-phase-1`

* Remove `SplitTestUserGetter.getProjection`

* Update tests

* Remove last mentions of `plans-2026-phase-1`

* In test, wait for `_updateCustomerIoSubscriptionProperties` to be done

* Remove `purchaseAddon` test

* Remove AI-addon tests

* Fix `_quotaTierToAllowance` throwing on zero-grant tiers

`!quota` treated `0` as invalid, so `basic` (grant 0) threw. Check `typeof quota !== 'number'` alone. Unmasked once branch removed the split-test branch that skipped this path.

* Extract `waitForCustomerIoSync` helper in FeaturesUpdater tests

* https://github.com/overleaf/internal/pull/34771#discussion_r3552882875

GitOrigin-RevId: 0fe0e41b7a4105a3c4739214e2fffb77e91dbc42
This commit is contained in:
Antoine Clausse
2026-09-17 11:23:39 +00:00
committed by Copybot
parent 414027160d
commit 882ca2f981
8 changed files with 62 additions and 658 deletions
@@ -492,7 +492,6 @@ const _ProjectController = {
'wf-enable-freemium-super-complete',
'wf-enable-super-complete-promotion',
'wf-rebrand',
'plans-2026-phase-1',
'testing-ai-usage',
'wf-fake-non-english-suggestions',
'editor-tabs',
@@ -539,7 +539,7 @@ async function projectListPage(req, res, next) {
const aiBlocked =
Features.hasFeature('saas') && !(await _canUseAIAssist(user))
const hasAiAssist =
Features.hasFeature('saas') && (await _userHasAIAssist(req, res, user))
Features.hasFeature('saas') && (await _userHasAIAssist(user))
const splitTests = [
// Split tests that will be made available to the frontend
@@ -949,24 +949,11 @@ function _hasActiveFilter(filters) {
}
/**
* @param {any} req
* @param {any} res
* @param {any} user
*/
// todo: quota clean-up: rename function and vars
async function _userHasAIAssist(req, res, user) {
let hasPremiumAiFeatures
const inQuotaSplitTest = await SplitTestHandler.promises.featureFlagEnabled(
req,
res,
'plans-2026-phase-1'
)
if (inQuotaSplitTest) {
hasPremiumAiFeatures =
user.features?.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
} else {
hasPremiumAiFeatures = user.features?.aiErrorAssistant === true
}
async function _userHasAIAssist(user) {
const hasPremiumAiFeatures =
user.features?.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
// Check if the user has a non free trial version of our AI features
if (hasPremiumAiFeatures) {
return true
@@ -20,8 +20,6 @@ import CustomerIoPlanHelpers from './CustomerIoPlanHelpers.mjs'
import { GroupPolicy } from '../../models/GroupPolicy.mjs'
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
import { fetchNothing } from '@overleaf/fetch-utils'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import SplitTestUserGetter from '../SplitTests/SplitTestUserGetter.mjs'
/**
* Enqueue a job for refreshing features for the given user
@@ -48,8 +46,8 @@ async function refreshFeatures(userId, reason) {
_id: 1,
features: 1,
email: 1,
// analyticsId + labsProgram (analytics) and the split test fields below
...SplitTestUserGetter.getProjection('plans-2026-phase-1'),
analyticsId: 1,
labsProgram: 1,
})
const oldFeatures = _.clone(user.features)
const features = await computeFeatures(userId)
@@ -94,19 +92,8 @@ async function refreshFeatures(userId, reason) {
// skip if they are the reason we are refreshing features (they'd already be up to date)
if (featuresChanged && reason !== 'writefullEntitlementSynced') {
try {
// todo: quota clean-up: simplify once split test isnt needed
let hasPremiumAiFeatures
const inQuotaSplitTest =
await SplitTestHandler.promises.featureFlagEnabledForMongoUser(
user,
'plans-2026-phase-1'
)
if (inQuotaSplitTest) {
hasPremiumAiFeatures =
newFeatures.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
} else {
hasPremiumAiFeatures = Boolean(newFeatures.aiErrorAssistant)
}
const hasPremiumAiFeatures =
newFeatures.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
// update WF with the current feature set for the user
await fetchNothing(
`${Settings.writefull.overleafApiUrl}/api/user/status/update-overleaf-status`,
@@ -22,27 +22,15 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
import Modules from '../../infrastructure/Modules.mjs'
import async from 'async'
import HttpErrorHandler from '../Errors/HttpErrorHandler.mjs'
import {
AI_ADD_ON_CODE,
subscriptionChangeIsAiAssistUpgrade,
} from './AiHelper.mjs'
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
import PlansLocator from './PlansLocator.mjs'
import { User } from '../../models/User.mjs'
import UserGetter from '../User/UserGetter.mjs'
import PermissionsManager from '../Authorization/PermissionsManager.mjs'
import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.mjs'
import { z, parseReq } from '../../infrastructure/Validation.mjs'
import SubscriptionLocator from './SubscriptionLocator.mjs'
import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
const {
DuplicateAddOnError,
AddOnNotPresentError,
PaymentActionRequiredError,
PaymentFailedError,
MissingBillingInfoError,
MultiplePendingChangesError,
} = Errors
const { AddOnNotPresentError, MultiplePendingChangesError } = Errors
const SUBSCRIPTION_PAUSED_REDIRECT_PATH =
'/user/subscription?redirect-reason=subscription-paused'
@@ -102,23 +90,6 @@ async function _checkRecurlySubscriptionPauseStatus(subscription) {
)
}
/** Check if a user's subscription is manual or custom
* @param {Record<string, any>} user - The user object
* @returns {Promise<boolean>}
*/
async function _isManualOrCustomSubscription(user) {
const subscription = await SubscriptionLocator.promises.getUsersSubscription(
user._id
)
if (!subscription) {
return false
}
return (
subscription.customAccount || subscription.collectionMethod === 'manual'
)
}
/**
* Check if a user's subscription is currently paused
* @param {Record<string, any>} user - The user object
@@ -190,7 +161,6 @@ async function userSubscriptionPage(req, res) {
res,
'combined-user-management'
)
await SplitTestHandler.promises.getAssignment(req, res, 'plans-2026-phase-1')
const groupPricingDiscount = await SplitTestHandler.promises.getAssignment(
req,
res,
@@ -531,241 +501,24 @@ function cancelV1Subscription(req, res, next) {
* @param {any} res
*/
async function previewAddonPurchase(req, res) {
const user = SessionManager.getSessionUser(req.session)
const userId = user._id
const addOnCode = req.params.addOnCode
const purchaseReferrer = req.query.purchaseReferrer
const redirectedPaymentErrorCode = req.query.errorCode
if (addOnCode !== AI_ADD_ON_CODE) {
return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
}
const { variant: plans2026Phase1Variant } =
await SplitTestHandler.promises.getAssignment(
req,
res,
'plans-2026-phase-1'
)
if (plans2026Phase1Variant === 'enabled') {
return res.redirect(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
}
const canUseAi = await PermissionsManager.promises.checkUserPermissions(
user,
['use-ai']
return res.redirect(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
if (!canUseAi) {
return res.redirect(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
}
const isManualOrCustom = await _isManualOrCustomSubscription(user)
if (isManualOrCustom) {
return res.redirect(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
}
const { isPaused, redirectPath } = await checkSubscriptionPauseStatus(user)
if (isPaused) {
return res.redirect(redirectPath)
}
let paymentMethod
try {
/** @type {PaymentMethod[]} */
paymentMethod = await Modules.promises.hooks.fire(
'getPaymentMethod',
userId
)
} catch (err) {
if (err instanceof MissingBillingInfoError) {
// We will get MissingBillingInfoError if a manual subscription doesn't have billing info
// but doesn't marked as manual on the Overleaf side
logger.error(
{ err },
'User has no billing info, cannot preview add-on purchase'
)
return res.redirect(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
}
if (
err instanceof Error &&
err.constructor.name === 'PaymentServiceResourceNotFoundError'
) {
return res.redirect(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
}
throw err
}
let subscriptionChange
try {
subscriptionChange =
await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode)
const { isPremium: hasAiAssistViaWritefull } =
await UserGetter.promises.getWritefullData(userId)
const isAiUpgrade = subscriptionChangeIsAiAssistUpgrade(subscriptionChange)
if (hasAiAssistViaWritefull && isAiUpgrade) {
return res.redirect(
'/user/subscription?redirect-reason=writefull-entitled'
)
}
} catch (err) {
if (err instanceof DuplicateAddOnError) {
return res.redirect('/user/subscription?redirect-reason=double-buy')
}
if (
err instanceof Error &&
err.constructor.name === 'PaymentServiceResourceNotFoundError'
) {
return res.redirect(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
}
throw err
}
const addOn = PlansLocator.findLocalPlanInSettings(addOnCode)
if (!addOn) {
return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
}
/** @type {SubscriptionChangePreview} */
const changePreview = makeChangePreview(
{
type: 'add-on-purchase',
addOn: {
code: addOn.planCode,
name: addOn.name,
},
},
subscriptionChange,
paymentMethod[0]
)
res.render('subscriptions/preview-change', {
changePreview,
purchaseReferrer,
redirectedPaymentErrorCode,
})
}
const purchaseAddonSchema = z.object({
params: z.object({
addOnCode: z.string(),
}),
})
/**
* @param {any} req
* @param {any} res
* @param {any} next
*/
async function purchaseAddon(req, res, next) {
const user = SessionManager.getSessionUser(req.session)
const { params } = parseReq(req, purchaseAddonSchema)
const addOnCode = params.addOnCode
// currently we only support having a quantity of 1
const quantity = 1
// currently we only support one add-on, the Ai add-on
if (addOnCode !== AI_ADD_ON_CODE) {
return res.sendStatus(404)
}
const { variant: plans2026Phase1Variant } =
await SplitTestHandler.promises.getAssignment(
req,
res,
'plans-2026-phase-1'
)
if (plans2026Phase1Variant === 'enabled') {
return res.sendStatus(404)
}
const { isPaused } = await checkSubscriptionPauseStatus(user)
if (isPaused) {
return HttpErrorHandler.badRequest(
req,
res,
'Cannot purchase add-ons while subscription is paused.'
)
}
logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons')
try {
await SubscriptionHandler.promises.purchaseAddon(
user._id,
addOnCode,
quantity
)
} catch (err) {
if (err instanceof DuplicateAddOnError) {
HttpErrorHandler.badRequest(
req,
res,
'Your subscription already includes this add-on',
{ addon: addOnCode }
)
} else if (err instanceof PaymentActionRequiredError) {
logger.debug(
{ userId: user._id },
'Customer needs to perform payment action to complete transaction'
)
return res.status(402).json({
message: 'Payment action required',
clientSecret: /** @type {any} */ (err).info.clientSecret,
publicKey: /** @type {any} */ (err).info.publicKey,
})
} else if (err instanceof PaymentFailedError) {
logger.debug(
{
userId: user._id,
reason: /** @type {any} */ (err).info.reason,
adviceCode: /** @type {any} */ (err).info.adviceCode,
},
'Payment failed for transaction'
)
return res.status(402).json({
message: 'Payment failed',
reason: /** @type {any} */ (err).info.reason,
adviceCode: /** @type {any} */ (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', {
user_id: user._id,
addOnCode,
})
}
return next(err)
}
}
try {
await FeaturesUpdater.promises.refreshFeatures(user._id, 'add-on-purchase')
} catch (err) {
logger.error({ err }, 'Failed to refresh features after add-on purchase')
}
return res.sendStatus(200)
return res.sendStatus(404)
}
const removeAddonSchema = z.object({
@@ -3,8 +3,6 @@
import UserGetter from '../../Features/User/UserGetter.mjs'
import FeatureUsageRateLimiter from './FeatureUsageRateLimiter.mjs'
import Settings from '@overleaf/settings'
import SplitTestHandler from '../../Features/SplitTests/SplitTestHandler.mjs'
import SplitTestUserGetter from '../../Features/SplitTests/SplitTestUserGetter.mjs'
import FeaturesHelper from '../../Features/Subscription/FeaturesHelper.mjs'
class AiFeatureUsageRateLimiter extends FeatureUsageRateLimiter {
@@ -20,31 +18,16 @@ class AiFeatureUsageRateLimiter extends FeatureUsageRateLimiter {
const user = await UserGetter.promises.getUser(userId, {
features: 1,
writefull: 1,
...SplitTestUserGetter.getProjection('plans-2026-phase-1'),
})
// todo: quota clean-up: remove aiErrorAssistant checking, and split test
const inQuotaSplitTest =
await SplitTestHandler.promises.featureFlagEnabledForMongoUser(
user,
'plans-2026-phase-1'
)
if (inQuotaSplitTest) {
const wfQuota = user.writefull?.isPremium
? Settings.writefull.quotaTierGranted
: Settings.aiFeatures.freeQuota
const mergedFeatures = FeaturesHelper.mergeFeatures(user.features, {
aiUsageQuota: wfQuota,
})
const quotaTier = mergedFeatures.aiUsageQuota
return _quotaTierToAllowance(quotaTier)
} else {
const DEFAULT_ALLOWANCE = 1
const ADD_ON_ALLOWANCE = 200
const hasAddOn =
user?.features?.aiErrorAssistant || user?.writefull?.isPremium
return hasAddOn ? ADD_ON_ALLOWANCE : DEFAULT_ALLOWANCE
}
const wfQuota = user?.writefull?.isPremium
? Settings.writefull.quotaTierGranted
: Settings.aiFeatures.freeQuota
const mergedFeatures = FeaturesHelper.mergeFeatures(user?.features, {
aiUsageQuota: wfQuota,
})
const quotaTier = mergedFeatures.aiUsageQuota
return _quotaTierToAllowance(quotaTier)
}
}
@@ -57,7 +40,7 @@ class AiFeatureUsageRateLimiter extends FeatureUsageRateLimiter {
*/
function _quotaTierToAllowance(quotaTier) {
const quota = Settings.quotaGrants.ai[quotaTier]
if (!quota || typeof quota !== 'number') {
if (typeof quota !== 'number') {
throw new Error(`Quota tier "${quotaTier}" is not initialized in settings`)
}
return Math.floor(quota)
@@ -1,6 +1,5 @@
// @ts-check
import SplitTestHandler from '../../Features/SplitTests/SplitTestHandler.mjs'
import SplitTestUserGetter from '../../Features/SplitTests/SplitTestUserGetter.mjs'
import UserGetter from '../../Features/User/UserGetter.mjs'
import TokenUsageRateLimiter from './TokenUsageRateLimiter.mjs'
/** @typedef {{usage?: number | null, periodStart?: Date | null}} FeatureUsage */
@@ -28,29 +27,15 @@ class WorkbenchRateLimiter extends TokenUsageRateLimiter {
return 0
}
const user = await UserGetter.promises.getUser(userId, {
features: 1,
writefull: 1,
...SplitTestUserGetter.getProjection('plans-2026-phase-1'),
alphaProgram: 1,
})
if (user?.alphaProgram) {
return ALPHA_USER_TOKEN_ALLOWANCE
}
// todo: quota clean-up: remove split test
let hasAddOn
const inQuotaSplitTest =
await SplitTestHandler.promises.featureFlagEnabledForMongoUser(
user,
'plans-2026-phase-1'
)
if (inQuotaSplitTest) {
// post rollout, all users have the same token limit (fair usage)
return DEFAULT_USER_TOKEN_ALLOWANCE
} else {
hasAddOn = user.features.aiErrorAssistant || user.writefull?.isPremium
return hasAddOn ? DEFAULT_USER_TOKEN_ALLOWANCE : 0
}
// all users have the same token limit (fair usage)
return DEFAULT_USER_TOKEN_ALLOWANCE
}
}
export default new WorkbenchRateLimiter()
@@ -7,6 +7,16 @@ const { ObjectId } = mongodb
const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater'
// helper awaiting _updateCustomerIoSubscriptionProperties called by refreshFeatures,
// which is otherwise fire-and-forget.
async function waitForCustomerIoSync(ctx) {
await vi.waitFor(() => {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties'
)
})
}
describe('FeaturesUpdater', function () {
beforeEach(async function (ctx) {
ctx.renewalDate = new Date('2099-04-01T00:00:00Z')
@@ -426,6 +436,7 @@ describe('FeaturesUpdater', function () {
it('should return features and featuresChanged', async function (ctx) {
const { features, featuresChanged } =
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(features).to.exist
expect(featuresChanged).to.exist
})
@@ -433,6 +444,7 @@ describe('FeaturesUpdater', function () {
describe('normally', function () {
beforeEach(async function (ctx) {
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should update the user with the merged features', function (ctx) {
@@ -504,6 +516,7 @@ describe('FeaturesUpdater', function () {
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync trial_end_date to customer.io', function (ctx) {
@@ -542,6 +555,7 @@ describe('FeaturesUpdater', function () {
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should report stripe as the payment_provider', function (ctx) {
@@ -568,6 +582,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync commons=true to customer.io', function (ctx) {
@@ -609,6 +624,7 @@ describe('FeaturesUpdater', function () {
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should set commons, individual_subscription, and ai-assist-add-on together', function (ctx) {
@@ -636,6 +652,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync false subscription flags and no payment_provider', function (ctx) {
@@ -682,6 +699,7 @@ describe('FeaturesUpdater', function () {
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync expiry_date and blank next_renewal_date in customer.io', function (ctx) {
@@ -727,6 +745,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should sync groupSize to customer.io', function (ctx) {
@@ -785,6 +804,7 @@ describe('FeaturesUpdater', function () {
.resolves([{ _id: policyId, userCannotUseAIFeatures: true }]),
})
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should set group_ai_enabled to false', function (ctx) {
@@ -824,6 +844,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should derive payment_provider from the group subscription', function (ctx) {
@@ -850,6 +871,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
@@ -867,6 +889,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
@@ -884,6 +907,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
@@ -901,6 +925,7 @@ describe('FeaturesUpdater', function () {
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
@@ -915,6 +940,7 @@ describe('FeaturesUpdater', function () {
.withArgs(ctx.user._id)
.resolves(null)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should send mixed feature set user property', function (ctx) {
@@ -934,6 +960,7 @@ describe('FeaturesUpdater', function () {
.withArgs(ctx.user._id)
.resolves(ctx.subscriptions.noDropbox)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
await waitForCustomerIoSync(ctx)
})
it('should fire module hook to unlink dropbox', function (ctx) {
@@ -913,152 +913,6 @@ describe('SubscriptionController', function () {
})
})
describe('purchaseAddon', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(ctx.user) // Make sure getSessionUser returns the user
ctx.next = sinon.stub()
ctx.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code
})
it('should return 200 on successful purchase of AI add-on', async function (ctx) {
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.have.been.called
expect(
ctx.SubscriptionHandler.promises.purchaseAddon
).to.have.been.calledWith(ctx.user._id, AI_ADD_ON_CODE, 1)
expect(
ctx.FeaturesUpdater.promises.refreshFeatures
).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
expect(ctx.res.sendStatus).to.have.been.calledWith(200)
expect(ctx.logger.debug).to.have.been.calledWith(
{ userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE },
'purchasing add-ons'
)
})
it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
ctx.req.params = { addOnCode: 'some-other-addon' }
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
.called
expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
.called
expect(ctx.res.sendStatus).to.have.been.calledWith(404)
})
it('should return 404 when plans-2026-phase-1 split test is enabled', async function (ctx) {
ctx.SplitTestV2Hander.promises.getAssignment.resolves({
variant: 'enabled',
})
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
.called
expect(ctx.res.sendStatus).to.have.been.calledWith(404)
})
it('should handle DuplicateAddOnError and send badRequest while sending 200', async function (ctx) {
ctx.req.params.addOnCode = AI_ADD_ON_CODE
ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
new SubscriptionErrors.DuplicateAddOnError()
)
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
ctx.req,
ctx.res,
'Your subscription already includes this add-on',
{ addon: AI_ADD_ON_CODE }
)
expect(
ctx.FeaturesUpdater.promises.refreshFeatures
).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
expect(ctx.res.sendStatus).toHaveBeenCalledWith(200)
})
it('should handle PaymentActionRequiredError and return 402 with details', async function (ctx) {
ctx.req.params.addOnCode = AI_ADD_ON_CODE
const paymentError = new SubscriptionErrors.PaymentActionRequiredError({
clientSecret: 'secret123',
publicKey: 'pubkey456',
})
ctx.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError)
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
expect(ctx.res.status).toHaveBeenCalledWith(402)
expect(ctx.res.json).toHaveBeenCalledWith({
message: 'Payment action required',
clientSecret: 'secret123',
publicKey: 'pubkey456',
})
expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
.called
})
it('should refresh features', async function (ctx) {
ctx.req.params.addOnCode = 'assistant'
ctx.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves()
ctx.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves()
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
expect(
ctx.FeaturesUpdater.promises.refreshFeatures.calledWith(
ctx.user._id,
'add-on-purchase'
)
).to.be.true
})
it('should respond with a bad request if the subscription already includes the addOn', async function (ctx) {
ctx.req.params.addOnCode = 'assistant'
ctx.SubscriptionHandler.promises.purchaseAddon = sinon
.stub()
.rejects(new SubscriptionErrors.DuplicateAddOnError())
await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
expect(
ctx.HttpErrorHandler.badRequest.calledWith(
ctx.req,
ctx.res,
'Your subscription already includes this add-on',
{ addon: 'assistant' }
)
).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 () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(ctx.user)
@@ -1812,21 +1666,12 @@ describe('SubscriptionController', function () {
describe('previewAddonPurchase', function () {
beforeEach(function (ctx) {
ctx.req = new MockRequest(vi)
ctx.req.params = { addOnCode: 'assistant' }
ctx.req.query = { purchaseReferrer: 'fake-referrer' }
ctx.res = new MockResponse(vi)
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentMethod')
.resolves(['fake-method'])
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
ctx.res.redirect = sinon.stub()
})
it('should redirect with ai-assist-unavailable when plans-2026-phase-1 is enabled', async function (ctx) {
ctx.SplitTestV2Hander.promises.getAssignment.resolves({
variant: 'enabled',
})
ctx.res.redirect = sinon.stub()
it('should redirect with ai-assist-unavailable for the AI add-on', async function (ctx) {
ctx.req.params = { addOnCode: AI_ADD_ON_CODE }
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
@@ -1835,177 +1680,15 @@ describe('SubscriptionController', function () {
)
})
describe('when user has manual or custom subscription', function () {
it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function (ctx) {
const customSubscription = {
_id: 'sub-123',
customAccount: true,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
customSubscription
)
it('should 404 for an unknown add-on', async function (ctx) {
ctx.req.params = { addOnCode: 'unknown-addon' }
ctx.res.redirect = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.redirect).to.have.been.calledWith(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
})
it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function (ctx) {
const manualSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'manual',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
manualSubscription
)
ctx.res.redirect = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.redirect).to.have.been.calledWith(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
})
it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function (ctx) {
const customManualSubscription = {
_id: 'sub-123',
customAccount: true,
collectionMethod: 'manual',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
customManualSubscription
)
ctx.res.redirect = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.redirect).to.have.been.calledWith(
'/user/subscription?redirect-reason=ai-assist-unavailable'
)
})
})
describe('when user has normal subscription', function () {
it('should proceed with preview when subscription is not manual or custom', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.PlansLocator.findLocalPlanInSettings.withArgs('assistant').returns({
planCode: 'assistant',
name: 'AI Assist',
annual: false,
})
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change',
sinon.match({
changePreview: sinon.match({
change: {
type: 'add-on-purchase',
addOn: { code: 'assistant', name: 'AI Assist' },
},
}),
purchaseReferrer: 'fake-referrer',
redirectedPaymentErrorCode: undefined,
})
)
expect(
ctx.SubscriptionHandler.promises.previewAddonPurchase
).to.have.been.calledWith(ctx.user._id, 'assistant')
expect(
ctx.PlansLocator.findLocalPlanInSettings
).to.have.been.calledWith('assistant')
})
it('should pass redirectedPaymentErrorCode to the view when errorCode query param is present', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.req.query.errorCode = 'payment_failed'
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change',
sinon.match({
changePreview: sinon.match.object,
purchaseReferrer: 'fake-referrer',
redirectedPaymentErrorCode: 'payment_failed',
})
)
})
it('returns 404 when the add-on code is not in the local plan registry', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.PlansLocator.findLocalPlanInSettings
.withArgs('assistant')
.returns(null)
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
ctx.req,
ctx.res,
'Unknown add-on: assistant'
)
expect(ctx.res.render).not.to.have.been.called
})
it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
// customAccount: undefined (not set)
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change'
)
expect(
ctx.SubscriptionHandler.promises.previewAddonPurchase
).to.have.been.calledWith(ctx.user._id, 'assistant')
})
expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
ctx.req,
ctx.res
)
})
})
})