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 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,
}

View file

@ -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,

View file

@ -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,
},
}

View file

@ -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(),

View file

@ -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

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 { 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 <CancelSubscription />
const aiAddOnAvailable = isSplitTestEnabled('ai-add-on')
return (
<>
<p>
@ -145,6 +148,7 @@ export function ActiveSubscription({
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<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'
// 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