mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[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:
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user