Explorar el Código

[web] support purchasing/removing add-ons for Stripe subscriptions (#26081)

GitOrigin-RevId: 01c2eaccc7c34bc37be43120de83270490e5e6da
Kristina hace 1 año
padre
commit
a134a2b799

+ 29 - 2
services/web/app/src/Features/Subscription/PlansLocator.js

@@ -4,7 +4,9 @@ const logger = require('@overleaf/logger')
 
 /**
  * @typedef {import('../../../../types/subscription/plan').RecurlyPlanCode} RecurlyPlanCode
+ * @typedef {import('../../../../types/subscription/plan').RecurlyAddOnCode} RecurlyAddOnCode
  * @typedef {import('../../../../types/subscription/plan').StripeLookupKey} StripeLookupKey
+ * @typedef {import('stripe').Stripe.Price.Recurring.Interval} BillingCycleInterval
  */
 
 function ensurePlansAreSetupCorrectly() {
@@ -38,7 +40,7 @@ const recurlyPlanCodeToStripeLookupKey = {
   group_professional_educational: 'group_professional_educational',
   group_collaborator: 'group_standard_enterprise',
   group_collaborator_educational: 'group_standard_educational',
-  assistant_annual: 'error_assist_annual',
+  'assistant-annual': 'error_assist_annual',
   assistant: 'error_assist_monthly',
 }
 
@@ -51,6 +53,28 @@ function mapRecurlyPlanCodeToStripeLookupKey(recurlyPlanCode) {
   return recurlyPlanCodeToStripeLookupKey[recurlyPlanCode]
 }
 
+/**
+ *
+ * @param {RecurlyAddOnCode} recurlyAddOnCode
+ * @param {BillingCycleInterval} billingCycleInterval
+ * @returns {StripeLookupKey|null}
+ */
+function mapRecurlyAddOnCodeToStripeLookupKey(
+  recurlyAddOnCode,
+  billingCycleInterval
+) {
+  // Recurly always uses 'assistant' as the code regardless of the subscription duration
+  if (recurlyAddOnCode === 'assistant') {
+    if (billingCycleInterval === 'month') {
+      return 'error_assist_monthly'
+    }
+    if (billingCycleInterval === 'year') {
+      return 'error_assist_annual'
+    }
+  }
+  return null
+}
+
 const recurlyPlanCodeToPlanTypeAndPeriod = {
   collaborator: { planType: 'individual', period: 'monthly' },
   collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' },
@@ -68,12 +92,14 @@ const recurlyPlanCodeToPlanTypeAndPeriod = {
   group_professional_educational: { planType: 'group', period: 'annual' },
   group_collaborator: { planType: 'group', period: 'annual' },
   group_collaborator_educational: { planType: 'group', period: 'annual' },
+  assistant: { planType: null, period: 'monthly' },
+  'assistant-annual': { planType: null, period: 'annual' },
 }
 
 /**
  *
  * @param {RecurlyPlanCode} recurlyPlanCode
- * @returns {{ planType: 'individual' | 'group' | 'student', period: 'annual' | 'monthly'}}
+ * @returns {{ planType: 'individual' | 'group' | 'student' | null, period: 'annual' | 'monthly'}}
  */
 function getPlanTypeAndPeriodFromRecurlyPlanCode(recurlyPlanCode) {
   return recurlyPlanCodeToPlanTypeAndPeriod[recurlyPlanCode]
@@ -92,5 +118,6 @@ module.exports = {
   ensurePlansAreSetupCorrectly,
   findLocalPlanInSettings,
   mapRecurlyPlanCodeToStripeLookupKey,
+  mapRecurlyAddOnCodeToStripeLookupKey,
   getPlanTypeAndPeriodFromRecurlyPlanCode,
 }

+ 0 - 2
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -414,8 +414,6 @@ async function purchaseAddon(req, res, next) {
 
   logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons')
   try {
-    // set a restore point in the case of a failed payment for the upgrade (Recurly only)
-    await SubscriptionHandler.promises.setSubscriptionRestorePoint(user._id)
     await SubscriptionHandler.promises.purchaseAddon(
       user._id,
       addOnCode,

+ 6 - 56
services/web/app/src/Features/Subscription/SubscriptionHandler.js

@@ -1,6 +1,5 @@
 // @ts-check
 
-const recurly = require('recurly')
 const RecurlyWrapper = require('./RecurlyWrapper')
 const RecurlyClient = require('./RecurlyClient')
 const { User } = require('../../models/User')
@@ -11,11 +10,11 @@ const LimitationsManager = require('./LimitationsManager')
 const EmailHandler = require('../Email/EmailHandler')
 const { callbackify } = require('@overleaf/promise-utils')
 const UserUpdater = require('../User/UserUpdater')
-const { NotFoundError, IndeterminateInvoiceError } = require('../Errors/Errors')
+const { IndeterminateInvoiceError } = require('../Errors/Errors')
 const Modules = require('../../infrastructure/Modules')
 
 /**
- * @import { PaymentProviderSubscription, PaymentProviderSubscriptionChange } from './PaymentProviderEntities'
+ * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities'
  */
 
 async function validateNoSubscriptionInRecurly(userId) {
@@ -278,24 +277,12 @@ async function previewAddonPurchase(userId, addOnCode) {
  * @param {number} quantity
  */
 async function purchaseAddon(userId, addOnCode, quantity) {
-  const subscription = await getSubscriptionForUser(userId)
-  try {
-    await RecurlyClient.promises.getAddOn(subscription.planCode, addOnCode)
-  } catch (err) {
-    if (err instanceof recurly.errors.NotFoundError) {
-      throw new NotFoundError({
-        message: 'Add-on not found',
-        info: { addOnCode },
-      })
-    }
-    throw err
-  }
-  const changeRequest = subscription.getRequestForAddOnPurchase(
+  await Modules.promises.hooks.fire(
+    'purchaseAddOn',
+    userId,
     addOnCode,
     quantity
   )
-  await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
-  await syncSubscription({ uuid: subscription.id }, userId)
 }
 
 /**
@@ -305,44 +292,7 @@ async function purchaseAddon(userId, addOnCode, quantity) {
  * @param {string} addOnCode
  */
 async function removeAddon(userId, addOnCode) {
-  const subscription = await getSubscriptionForUser(userId)
-  const changeRequest = subscription.getRequestForAddOnRemoval(addOnCode)
-  await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
-  await syncSubscription({ uuid: subscription.id }, userId)
-}
-
-/**
- * Returns the Recurly UUID for the given user
- *
- * Throws a NotFoundError if the subscription can't be found
- *
- * @param {string} userId
- * @return {Promise<PaymentProviderSubscription>}
- */
-async function getSubscriptionForUser(userId) {
-  const subscription =
-    await SubscriptionLocator.promises.getUsersSubscription(userId)
-  const recurlyId = subscription?.recurlySubscription_id
-  if (recurlyId == null) {
-    throw new NotFoundError({
-      message: 'Recurly subscription not found',
-      info: { userId },
-    })
-  }
-
-  try {
-    const subscription = await RecurlyClient.promises.getSubscription(recurlyId)
-    return subscription
-  } catch (err) {
-    if (err instanceof recurly.errors.NotFoundError) {
-      throw new NotFoundError({
-        message: 'Subscription not found',
-        info: { userId, recurlyId },
-      })
-    } else {
-      throw err
-    }
-  }
+  await Modules.promises.hooks.fire('removeAddOn', userId, addOnCode)
 }
 
 async function pauseSubscription(user, pauseCycles) {

+ 41 - 0
services/web/test/unit/src/Subscription/PlansLocatorTests.js

@@ -29,6 +29,7 @@ const plans = [
 describe('PlansLocator', function () {
   beforeEach(function () {
     this.settings = { plans }
+    this.AI_ADD_ON_CODE = 'assistant'
 
     this.PlansLocator = SandboxedModule.require(modulePath, {
       requires: {
@@ -114,6 +115,46 @@ describe('PlansLocator', function () {
     })
   })
 
+  describe('mapRecurlyAddOnCodeToStripeLookupKey', function () {
+    it('should return null for unknown add-on codes', function () {
+      const billingCycleInterval = 'month'
+      const addOnCode = 'unknown_addon'
+      const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey(
+        addOnCode,
+        billingCycleInterval
+      )
+      expect(lookupKey).to.equal(null)
+    })
+
+    it('should handle missing input', function () {
+      const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey(
+        undefined,
+        undefined
+      )
+      expect(lookupKey).to.equal(null)
+    })
+
+    it('returns the key for a monthly AI assist add-on', function () {
+      const billingCycleInterval = 'month'
+      const addOnCode = this.AI_ADD_ON_CODE
+      const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey(
+        addOnCode,
+        billingCycleInterval
+      )
+      expect(lookupKey).to.equal('error_assist_monthly')
+    })
+
+    it('returns the key for an annual AI assist add-on', function () {
+      const billingCycleInterval = 'year'
+      const addOnCode = this.AI_ADD_ON_CODE
+      const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey(
+        addOnCode,
+        billingCycleInterval
+      )
+      expect(lookupKey).to.equal('error_assist_annual')
+    })
+  })
+
   describe('getPlanTypeAndPeriodFromRecurlyPlanCode', function () {
     it('should return the plan type and period for "collaborator"', function () {
       const { planType, period } =

+ 6 - 0
services/web/types/subscription/plan.ts

@@ -85,6 +85,10 @@ export type RecurlyPlanCode =
   | 'group_professional_educational'
   | 'group_collaborator'
   | 'group_collaborator_educational'
+  | 'assistant'
+  | 'assistant-annual'
+
+export type RecurlyAddOnCode = 'assistant'
 
 export type StripeLookupKey =
   | 'standard_monthly'
@@ -97,3 +101,5 @@ export type StripeLookupKey =
   | 'group_professional_enterprise'
   | 'group_standard_educational'
   | 'group_professional_educational'
+  | 'error_assist_annual'
+  | 'error_assist_monthly'