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
This commit is contained in:
Jimmy Domagala-Tang 2024-10-10 10:44:18 -04:00 committed by Copybot
parent 438192fc11
commit aa9a246346
9 changed files with 244 additions and 9 deletions

View file

@ -1,4 +1,5 @@
const Errors = require('../Errors/Errors') const Errors = require('../Errors/Errors')
const OError = require('@overleaf/o-error')
class RecurlyTransactionError extends Errors.BackwardCompatibleError { class RecurlyTransactionError extends Errors.BackwardCompatibleError {
constructor(options) { 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 = { module.exports = {
RecurlyTransactionError, RecurlyTransactionError,
DuplicateAddOnError,
AddOnNotPresentError,
NoRecurlySubscriptionError,
} }

View file

@ -15,6 +15,7 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager')
const RecurlyEventHandler = require('./RecurlyEventHandler') const RecurlyEventHandler = require('./RecurlyEventHandler')
const { expressify } = require('@overleaf/promise-utils') const { expressify } = require('@overleaf/promise-utils')
const OError = require('@overleaf/o-error') const OError = require('@overleaf/o-error')
const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
const SplitTestHandler = require('../SplitTests/SplitTestHandler') const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const SubscriptionHelper = require('./SubscriptionHelper') const SubscriptionHelper = require('./SubscriptionHelper')
const AuthorizationManager = require('../Authorization/AuthorizationManager') const AuthorizationManager = require('../Authorization/AuthorizationManager')
@ -22,8 +23,11 @@ const Modules = require('../../infrastructure/Modules')
const async = require('async') const async = require('async')
const { formatCurrencyLocalized } = require('../../util/currency') const { formatCurrencyLocalized } = require('../../util/currency')
const SubscriptionFormatters = require('./SubscriptionFormatters') const SubscriptionFormatters = require('./SubscriptionFormatters')
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const { URLSearchParams } = require('url') const { URLSearchParams } = require('url')
const AI_ADDON_CODE = 'assistant'
const groupPlanModalOptions = Settings.groupPlanModalOptions const groupPlanModalOptions = Settings.groupPlanModalOptions
const validGroupPlanModalOptions = { const validGroupPlanModalOptions = {
plan_code: groupPlanModalOptions.plan_codes.map(item => item.code), 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) { function updateSubscription(req, res, next) {
const origin = req && req.query ? req.query.origin : null const origin = req && req.query ? req.query.origin : null
const user = SessionManager.getSessionUser(req.session) const user = SessionManager.getSessionUser(req.session)
@ -771,6 +838,8 @@ module.exports = {
refreshUserFeatures: expressify(refreshUserFeatures), refreshUserFeatures: expressify(refreshUserFeatures),
redirectToHostedPage: expressify(redirectToHostedPage), redirectToHostedPage: expressify(redirectToHostedPage),
plansBanners: _plansBanners, plansBanners: _plansBanners,
purchaseAddon,
removeAddon,
promises: { promises: {
getRecommendedCurrency: _getRecommendedCurrency, getRecommendedCurrency: _getRecommendedCurrency,
getLatamCountryBannerDetails, getLatamCountryBannerDetails,

View file

@ -9,6 +9,11 @@ const PlansLocator = require('./PlansLocator')
const SubscriptionHelper = require('./SubscriptionHelper') const SubscriptionHelper = require('./SubscriptionHelper')
const { callbackify } = require('@overleaf/promise-utils') const { callbackify } = require('@overleaf/promise-utils')
const UserUpdater = require('../User/UserUpdater') const UserUpdater = require('../User/UserUpdater')
const {
DuplicateAddOnError,
AddOnNotPresentError,
NoRecurlySubscriptionError,
} = require('./Errors')
async function validateNoSubscriptionInRecurly(userId) { async function validateNoSubscriptionInRecurly(userId) {
let subscriptions = let subscriptions =
@ -86,8 +91,8 @@ async function updateSubscription(user, planCode, couponCode) {
couponCode couponCode
) )
} }
let changeAtTermEnd let changeAtTermEnd
const currentPlan = PlansLocator.findLocalPlanInSettings( const currentPlan = PlansLocator.findLocalPlanInSettings(
subscription.planCode subscription.planCode
) )
@ -106,19 +111,28 @@ async function updateSubscription(user, planCode, couponCode) {
} }
const timeframe = changeAtTermEnd ? 'term_end' : 'now' const timeframe = changeAtTermEnd ? 'term_end' : 'now'
const subscriptionChangeOptions = { planCode, timeframe }
await RecurlyClient.promises.changeSubscriptionByUuid( await _updateAndSyncSubscription(
user,
subscription.recurlySubscription_id, 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 // 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 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() // we need to be able to build a 'recurlySubscription' object for syncSubscription()
await syncSubscription( await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
{ uuid: subscription.recurlySubscription_id },
user._id
)
} }
async function cancelPendingSubscriptionChange(user) { 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 = { module.exports = {
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly), validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
createSubscription: callbackify(createSubscription), createSubscription: callbackify(createSubscription),
@ -265,6 +337,8 @@ module.exports = {
syncSubscription: callbackify(syncSubscription), syncSubscription: callbackify(syncSubscription),
attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection), attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
extendTrial: callbackify(extendTrial), extendTrial: callbackify(extendTrial),
purchaseAddon: callbackify(purchaseAddon),
removeAddon: callbackify(removeAddon),
promises: { promises: {
validateNoSubscriptionInRecurly, validateNoSubscriptionInRecurly,
createSubscription, createSubscription,
@ -275,5 +349,7 @@ module.exports = {
syncSubscription, syncSubscription,
attemptPaypalInvoiceCollection, attemptPaypalInvoiceCollection,
extendTrial, extendTrial,
purchaseAddon,
removeAddon,
}, },
} }

View file

@ -6,6 +6,7 @@ const TeamInvitesController = require('./TeamInvitesController')
const { RateLimiter } = require('../../infrastructure/RateLimiter') const { RateLimiter } = require('../../infrastructure/RateLimiter')
const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware') const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware')
const Settings = require('@overleaf/settings') const Settings = require('@overleaf/settings')
const { Joi, validate } = require('../../infrastructure/Validation')
const teamInviteRateLimiter = new RateLimiter('team-invite', { const teamInviteRateLimiter = new RateLimiter('team-invite', {
points: 10, points: 10,
@ -119,6 +120,28 @@ module.exports = {
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter), RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionController.updateSubscription 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( webRouter.post(
'/user/subscription/cancel-pending', '/user/subscription/cancel-pending',
AuthenticationController.requireLogin(), AuthenticationController.requireLogin(),

View file

@ -270,6 +270,7 @@ async function buildUsersSubscriptionViewModel(
billingDetailsLink: buildHostedLink('billing-details'), billingDetailsLink: buildHostedLink('billing-details'),
accountManagementLink: buildHostedLink('account-management'), accountManagementLink: buildHostedLink('account-management'),
additionalLicenses, additionalLicenses,
addOns: recurlySubscription.subscription_add_ons || [],
totalLicenses, totalLicenses,
nextPaymentDueAt: SubscriptionFormatters.formatDate( nextPaymentDueAt: SubscriptionFormatters.formatDate(
recurlySubscription.current_period_ends_at recurlySubscription.current_period_ends_at

View file

@ -15,6 +15,8 @@ import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal' import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal' import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import OLButton from '@/features/ui/components/ol/ol-button' 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({ export function ActiveSubscription({
subscription, subscription,
@ -27,6 +29,7 @@ export function ActiveSubscription({
if (showCancellation) return <CancelSubscription /> if (showCancellation) return <CancelSubscription />
const aiAddOnAvailable = isSplitTestEnabled('ai-add-on')
return ( return (
<> <>
<p> <p>
@ -145,6 +148,7 @@ export function ActiveSubscription({
<ConfirmChangePlanModal /> <ConfirmChangePlanModal />
<KeepCurrentPlanModal /> <KeepCurrentPlanModal />
<ChangeToGroupModal /> <ChangeToGroupModal />
{aiAddOnAvailable && <BuyAiAddOnButton />}
</> </>
) )
} }

View file

@ -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 ? (
<Button onClick={removeAiAddon}>Cancel AI subscription</Button>
) : (
<Button onClick={addAiAddon}>Purchase AI subscription </Button>
)
}

View file

@ -5,7 +5,22 @@ import { User } from '../../user'
type SubscriptionState = 'active' | 'canceled' | 'expired' 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 = { type Recurly = {
addOns?: AddOn[]
tax: number tax: number
taxRate: number taxRate: number
billingDetailsLink: string billingDetailsLink: string