mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
438192fc11
commit
aa9a246346
9 changed files with 244 additions and 9 deletions
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue