Explorar o código

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
Jimmy Domagala-Tang hai 1 ano
pai
achega
aa9a246346

+ 10 - 0
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,
 }

+ 69 - 0
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,

+ 83 - 7
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'
+  const subscriptionChangeOptions = { planCode, timeframe }
+  await _updateAndSyncSubscription(
+    user,
+    subscription.recurlySubscription_id,
+    subscriptionChangeOptions
+  )
+}
 
+async function _updateAndSyncSubscription(
+  user,
+  recurlySubscriptionId,
+  subscriptionChangeOptions
+) {
   await RecurlyClient.promises.changeSubscriptionByUuid(
-    subscription.recurlySubscription_id,
-    { planCode, timeframe }
+    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,
   },
 }

+ 23 - 0
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(),

+ 1 - 0
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

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

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

+ 1 - 1
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'

+ 15 - 0
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