diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js
index 3d97f6ebbe..e035e52c0e 100644
--- a/services/web/app/src/Features/Subscription/Errors.js
+++ b/services/web/app/src/Features/Subscription/Errors.js
@@ -1,4 +1,5 @@
const Errors = require('../Errors/Errors')
+const OError = require('@overleaf/o-error')
class RecurlyTransactionError extends Errors.BackwardCompatibleError {
constructor(options) {
@@ -9,6 +10,15 @@ class RecurlyTransactionError extends Errors.BackwardCompatibleError {
}
}
+class DuplicateAddOnError extends OError {}
+
+class AddOnNotPresentError extends OError {}
+
+class NoRecurlySubscriptionError extends OError {}
+
module.exports = {
RecurlyTransactionError,
+ DuplicateAddOnError,
+ AddOnNotPresentError,
+ NoRecurlySubscriptionError,
}
diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js
index e9fd948b63..d9674e5265 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionController.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionController.js
@@ -15,6 +15,7 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager')
const RecurlyEventHandler = require('./RecurlyEventHandler')
const { expressify } = require('@overleaf/promise-utils')
const OError = require('@overleaf/o-error')
+const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const SubscriptionHelper = require('./SubscriptionHelper')
const AuthorizationManager = require('../Authorization/AuthorizationManager')
@@ -22,8 +23,11 @@ const Modules = require('../../infrastructure/Modules')
const async = require('async')
const { formatCurrencyLocalized } = require('../../util/currency')
const SubscriptionFormatters = require('./SubscriptionFormatters')
+const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const { URLSearchParams } = require('url')
+const AI_ADDON_CODE = 'assistant'
+
const groupPlanModalOptions = Settings.groupPlanModalOptions
const validGroupPlanModalOptions = {
plan_code: groupPlanModalOptions.plan_codes.map(item => item.code),
@@ -484,6 +488,69 @@ function cancelV1Subscription(req, res, next) {
})
}
+async function purchaseAddon(req, res, next) {
+ const user = SessionManager.getSessionUser(req.session)
+ const addOnCode = req.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_ADDON_CODE) {
+ return res.sendStatus(404)
+ }
+
+ logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons')
+ try {
+ await SubscriptionHandler.promises.purchaseAddon(user, addOnCode, quantity)
+ return res.sendStatus(200)
+ } catch (err) {
+ if (err instanceof DuplicateAddOnError) {
+ HttpErrorHandler.badRequest(
+ req,
+ res,
+ 'Your subscription already includes this add-on',
+ { addon: addOnCode }
+ )
+ } else {
+ OError.tag(err, 'something went wrong purchasing add-ons', {
+ user_id: user._id,
+ addOnCode,
+ })
+ return next(err)
+ }
+ }
+}
+
+async function removeAddon(req, res, next) {
+ const user = SessionManager.getSessionUser(req.session)
+ const addOnCode = req.params.addOnCode
+
+ if (addOnCode !== AI_ADDON_CODE) {
+ return res.sendStatus(404)
+ }
+
+ logger.debug({ userId: user._id, addOnCode }, 'removing add-ons')
+
+ try {
+ await SubscriptionHandler.promises.removeAddon(user, addOnCode)
+ res.sendStatus(200)
+ } catch (err) {
+ if (err instanceof AddOnNotPresentError) {
+ HttpErrorHandler.badRequest(
+ req,
+ res,
+ 'Your subscription does not contain the requested add-on',
+ { addon: addOnCode }
+ )
+ } else {
+ OError.tag(err, 'something went wrong removing add-ons', {
+ user_id: user._id,
+ addOnCode,
+ })
+ return next(err)
+ }
+ }
+}
+
function updateSubscription(req, res, next) {
const origin = req && req.query ? req.query.origin : null
const user = SessionManager.getSessionUser(req.session)
@@ -771,6 +838,8 @@ module.exports = {
refreshUserFeatures: expressify(refreshUserFeatures),
redirectToHostedPage: expressify(redirectToHostedPage),
plansBanners: _plansBanners,
+ purchaseAddon,
+ removeAddon,
promises: {
getRecommendedCurrency: _getRecommendedCurrency,
getLatamCountryBannerDetails,
diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js
index 1f75dc8d49..766e3272a8 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js
@@ -9,6 +9,11 @@ const PlansLocator = require('./PlansLocator')
const SubscriptionHelper = require('./SubscriptionHelper')
const { callbackify } = require('@overleaf/promise-utils')
const UserUpdater = require('../User/UserUpdater')
+const {
+ DuplicateAddOnError,
+ AddOnNotPresentError,
+ NoRecurlySubscriptionError,
+} = require('./Errors')
async function validateNoSubscriptionInRecurly(userId) {
let subscriptions =
@@ -86,8 +91,8 @@ async function updateSubscription(user, planCode, couponCode) {
couponCode
)
}
-
let changeAtTermEnd
+
const currentPlan = PlansLocator.findLocalPlanInSettings(
subscription.planCode
)
@@ -106,19 +111,28 @@ async function updateSubscription(user, planCode, couponCode) {
}
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
-
- await RecurlyClient.promises.changeSubscriptionByUuid(
+ const subscriptionChangeOptions = { planCode, timeframe }
+ await _updateAndSyncSubscription(
+ user,
subscription.recurlySubscription_id,
- { planCode, timeframe }
+ subscriptionChangeOptions
+ )
+}
+
+async function _updateAndSyncSubscription(
+ user,
+ recurlySubscriptionId,
+ subscriptionChangeOptions
+) {
+ await RecurlyClient.promises.changeSubscriptionByUuid(
+ recurlySubscriptionId,
+ subscriptionChangeOptions
)
// v2 recurly API wants a UUID, but UUID isn't included in the subscription change response
// we got the UUID from the DB using userHasV2Subscription() - it is the only property
// we need to be able to build a 'recurlySubscription' object for syncSubscription()
- await syncSubscription(
- { uuid: subscription.recurlySubscription_id },
- user._id
- )
+ await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
}
async function cancelPendingSubscriptionChange(user) {
@@ -255,6 +269,64 @@ async function _updateSubscriptionFromRecurly(subscription) {
)
}
+async function _getSubscription(user) {
+ const { hasSubscription = false, subscription } =
+ await LimitationsManager.promises.userHasV2Subscription(user)
+
+ if (!hasSubscription || !subscription?.recurlySubscription_id) {
+ throw new NoRecurlySubscriptionError(
+ "could not fetch the user's Recurly subscription",
+ { userId: user._id }
+ )
+ }
+
+ const currentSub = await RecurlyClient.promises.getSubscription(
+ `uuid-${subscription.recurlySubscription_id}`
+ )
+ return currentSub
+}
+
+async function purchaseAddon(user, addOnCode, quantity) {
+ const subscription = await _getSubscription(user)
+ const currentAddons = subscription?.addOns || []
+
+ const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode)
+ if (hasAddon) {
+ throw new DuplicateAddOnError('User already has add-on', {
+ userId: user._id,
+ addOnCode,
+ })
+ }
+ const addOns = [...currentAddons, { code: addOnCode, quantity }]
+ const subscriptionChangeOptions = { addOns, timeframe: 'now' }
+
+ await _updateAndSyncSubscription(
+ user,
+ subscription.uuid,
+ subscriptionChangeOptions
+ )
+}
+
+async function removeAddon(user, addOnCode) {
+ const subscription = await _getSubscription(user)
+ const currentAddons = subscription?.addOns || []
+
+ const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode)
+ if (!hasAddon) {
+ throw new AddOnNotPresentError('User does not have add-on to remove', {
+ userId: user._id,
+ addOnCode,
+ })
+ }
+ const addOns = currentAddons.filter(addOn => addOn.addOn.code !== addOnCode)
+ const subscriptionChangeOptions = { addOns, timeframe: 'term_end' }
+ await _updateAndSyncSubscription(
+ user,
+ subscription.uuid,
+ subscriptionChangeOptions
+ )
+}
+
module.exports = {
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
createSubscription: callbackify(createSubscription),
@@ -265,6 +337,8 @@ module.exports = {
syncSubscription: callbackify(syncSubscription),
attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
extendTrial: callbackify(extendTrial),
+ purchaseAddon: callbackify(purchaseAddon),
+ removeAddon: callbackify(removeAddon),
promises: {
validateNoSubscriptionInRecurly,
createSubscription,
@@ -275,5 +349,7 @@ module.exports = {
syncSubscription,
attemptPaypalInvoiceCollection,
extendTrial,
+ purchaseAddon,
+ removeAddon,
},
}
diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.js b/services/web/app/src/Features/Subscription/SubscriptionRouter.js
index ecda019da7..a386fe4944 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionRouter.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.js
@@ -6,6 +6,7 @@ const TeamInvitesController = require('./TeamInvitesController')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware')
const Settings = require('@overleaf/settings')
+const { Joi, validate } = require('../../infrastructure/Validation')
const teamInviteRateLimiter = new RateLimiter('team-invite', {
points: 10,
@@ -119,6 +120,28 @@ module.exports = {
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionController.updateSubscription
)
+ webRouter.post(
+ '/user/subscription/addon/:addOnCode/add',
+ AuthenticationController.requireLogin(),
+ validate({
+ params: Joi.object({
+ addOnCode: Joi.string(),
+ }),
+ }),
+ RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+ SubscriptionController.purchaseAddon
+ )
+ webRouter.post(
+ '/user/subscription/addon/:addOnCode/remove',
+ AuthenticationController.requireLogin(),
+ validate({
+ params: Joi.object({
+ addOnCode: Joi.string(),
+ }),
+ }),
+ RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+ SubscriptionController.removeAddon
+ )
webRouter.post(
'/user/subscription/cancel-pending',
AuthenticationController.requireLogin(),
diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
index eb180b895c..2bd3722a27 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
@@ -270,6 +270,7 @@ async function buildUsersSubscriptionViewModel(
billingDetailsLink: buildHostedLink('billing-details'),
accountManagementLink: buildHostedLink('account-management'),
additionalLicenses,
+ addOns: recurlySubscription.subscription_add_ons || [],
totalLicenses,
nextPaymentDueAt: SubscriptionFormatters.formatDate(
recurlySubscription.current_period_ends_at
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
index b24a71113e..81c21609df 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
@@ -15,6 +15,8 @@ import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
+import { BuyAiAddOnButton } from './change-plan/modals/buy-ai-add-on-modal'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export function ActiveSubscription({
subscription,
@@ -27,6 +29,7 @@ export function ActiveSubscription({
if (showCancellation) return
@@ -145,6 +148,7 @@ export function ActiveSubscription({