[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()