From aa9a246346a9d9fd376b6f5d3c5810afec64b008 Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Thu, 10 Oct 2024 10:44:18 -0400 Subject: [PATCH] Merge pull request #20571 from overleaf/jdt-ai-addon-personal-upgrade Enable purchasing an AI add-on for users on a monthly collaborator plan GitOrigin-RevId: 988547bf6f01f7c1477191dd202df4448e376e5f --- .../app/src/Features/Subscription/Errors.js | 10 ++ .../Subscription/SubscriptionController.js | 69 ++++++++++++++ .../Subscription/SubscriptionHandler.js | 92 +++++++++++++++++-- .../Subscription/SubscriptionRouter.js | 23 +++++ .../SubscriptionViewModelBuilder.js | 1 + .../dashboard/states/active/active.tsx | 4 + .../modals/buy-ai-add-on-modal.tsx | 37 ++++++++ .../subscription/data/subscription-url.ts | 2 +- .../subscription/dashboard/subscription.ts | 15 +++ 9 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/buy-ai-add-on-modal.tsx 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 + const aiAddOnAvailable = isSplitTestEnabled('ai-add-on') return ( <>

@@ -145,6 +148,7 @@ export function ActiveSubscription({ + {aiAddOnAvailable && } ) } diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/buy-ai-add-on-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/buy-ai-add-on-modal.tsx new file mode 100644 index 0000000000..d324ac0261 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/buy-ai-add-on-modal.tsx @@ -0,0 +1,37 @@ +import { Button } from 'react-bootstrap' +import { postJSON } from '@/infrastructure/fetch-json' +import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context' +import { RecurlySubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription' + +const AI_ADDON_CODE = 'assistant' + +export function BuyAiAddOnButton() { + const { personalSubscription } = useSubscriptionDashboardContext() + const recurlySub = personalSubscription as RecurlySubscription + + const hasSub = recurlySub?.recurly?.addOns?.some( + addOn => addOn.add_on_code === AI_ADDON_CODE + ) + + const paths = { + purchaseAddon: `/user/subscription/addon/${AI_ADDON_CODE}/add`, + removeAddon: `/user/subscription/addon/${AI_ADDON_CODE}/remove`, + } + + const addAiAddon = async () => { + await postJSON(paths.purchaseAddon) + location.reload() + } + + // gotta change here to change the time to next billing cycle + const removeAiAddon = async () => { + await postJSON(paths.removeAddon) + location.reload() + } + + return hasSub ? ( + + ) : ( + + ) +} diff --git a/services/web/frontend/js/features/subscription/data/subscription-url.ts b/services/web/frontend/js/features/subscription/data/subscription-url.ts index 01ad6a0fe5..9dc294b4f6 100644 --- a/services/web/frontend/js/features/subscription/data/subscription-url.ts +++ b/services/web/frontend/js/features/subscription/data/subscription-url.ts @@ -4,4 +4,4 @@ export const cancelPendingSubscriptionChangeUrl = export const cancelSubscriptionUrl = '/user/subscription/cancel' export const redirectAfterCancelSubscriptionUrl = '/user/subscription/canceled' export const extendTrialUrl = '/user/subscription/extend' -export const reactivateSubscriptionUrl = '/user/subscription/reactivate' +export const reactivateSubscriptionUrl = '/user/subscription/reactivate' \ No newline at end of file diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index 2836a5ebeb..047dade746 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -5,7 +5,22 @@ import { User } from '../../user' type SubscriptionState = 'active' | 'canceled' | 'expired' +// the add-ons attached to a recurly subsription +export type AddOn = { + add_on_code: string + add_on_type: string + quantity: number + revenue_schedule_type: string + unit_amount_in_cents: number +} + +// when puchasing a new add-on in recurly, we only need to provide the code +export type PurchasingAddOnCode = { + code: string +} + type Recurly = { + addOns?: AddOn[] tax: number taxRate: number billingDetailsLink: string