mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-14 20:40:17 -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 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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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'
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue