Просмотр исходного кода

Merge pull request #28110 from overleaf/ls-handle-manual-subscription-on-add-on-purchase-page

Handle manual subscription on AddOn purchase page

GitOrigin-RevId: 54281d3471d7c2b60d333e6264904b3744156138
Liangjun Song 1 год назад
Родитель
Сommit
9a7bc564c1

+ 47 - 5
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -21,6 +21,7 @@ const {
   AddOnNotPresentError,
   PaymentActionRequiredError,
   PaymentFailedError,
+  MissingBillingInfoError,
 } = require('./Errors')
 const SplitTestHandler = require('../SplitTests/SplitTestHandler')
 const AuthorizationManager = require('../Authorization/AuthorizationManager')
@@ -40,6 +41,7 @@ const {
   sanitizeSessionUserForFrontEnd,
 } = require('../../infrastructure/FrontEndUser')
 const { IndeterminateInvoiceError } = require('../Errors/Errors')
+const SubscriptionLocator = require('./SubscriptionLocator')
 
 const SUBSCRIPTION_PAUSED_REDIRECT_PATH =
   '/user/subscription?redirect-reason=subscription-paused'
@@ -93,6 +95,23 @@ async function _checkRecurlySubscriptionPauseStatus(subscription) {
   )
 }
 
+/** Check if a user's subscription is manual or custom
+ * @param {Object} user - The user object
+ * @returns {Promise<boolean>}
+ */
+async function _isManualOrCustomSubscription(user) {
+  const subscription = await SubscriptionLocator.promises.getUsersSubscription(
+    user._id
+  )
+  if (!subscription) {
+    return false
+  }
+
+  return (
+    subscription.customAccount || subscription.collectionMethod === 'manual'
+  )
+}
+
 /**
  * Check if a user's subscription is currently paused
  * @param {Object} user - The user object
@@ -460,16 +479,39 @@ async function previewAddonPurchase(req, res) {
     )
   }
 
+  const isManualOrCustom = await _isManualOrCustomSubscription(user)
+  if (isManualOrCustom) {
+    return res.redirect(
+      '/user/subscription?redirect-reason=ai-assist-unavailable'
+    )
+  }
+
   const { isPaused, redirectPath } = await checkSubscriptionPauseStatus(user)
   if (isPaused) {
     return res.redirect(redirectPath)
   }
 
-  /** @type {PaymentMethod[]} */
-  const paymentMethod = await Modules.promises.hooks.fire(
-    'getPaymentMethod',
-    userId
-  )
+  let paymentMethod
+  try {
+    /** @type {PaymentMethod[]} */
+    paymentMethod = await Modules.promises.hooks.fire(
+      'getPaymentMethod',
+      userId
+    )
+  } catch (err) {
+    if (err instanceof MissingBillingInfoError) {
+      // We will get MissingBillingInfoError if a manual subscription doesn't have billing info
+      // but doesn't marked as manual on the Overleaf side
+      logger.error(
+        { err },
+        'User has no billing info, cannot preview add-on purchase'
+      )
+      return res.redirect(
+        '/user/subscription?redirect-reason=ai-assist-unavailable'
+      )
+    }
+    throw err
+  }
 
   let subscriptionChange
   try {

+ 173 - 0
services/web/test/unit/src/Subscription/SubscriptionControllerTests.js

@@ -63,6 +63,22 @@ describe('SubscriptionController', function () {
         attemptPaypalInvoiceCollection: sinon.stub().resolves(),
         startFreeTrial: sinon.stub().resolves(),
         purchaseAddon: sinon.stub().resolves(),
+        previewAddonPurchase: sinon.stub().resolves({
+          subscription: {
+            currency: 'USD',
+            netTerms: 0,
+            periodEnd: new Date(),
+            taxRate: 0,
+          },
+          immediateCharge: { amount: 0 },
+          nextPlanCode: 'professional',
+          nextPlanName: 'Professional',
+          nextPlanPrice: 2000,
+          nextAddOns: [],
+          subtotal: 2000,
+          tax: 0,
+          total: 2000,
+        }),
       },
     }
 
@@ -201,6 +217,29 @@ describe('SubscriptionController', function () {
             findById: sinon.stub().resolves(this.user),
           },
         },
+        './SubscriptionLocator': (this.SubscriptionLocator = {
+          promises: {
+            getUsersSubscription: sinon.stub().resolves(null),
+          },
+        }),
+        '../Authorization/PermissionsManager': (this.PermissionsManager = {
+          promises: {
+            checkUserPermissions: sinon.stub().resolves(true),
+          },
+        }),
+        './RecurlyClient': (this.RecurlyClient = {
+          promises: {
+            getAddOn: sinon.stub().resolves({
+              code: 'ai-assistant',
+              name: 'AI Assistant',
+            }),
+          },
+        }),
+        './PlansLocator': (this.PlansLocator = {
+          findLocalPlanInSettings: sinon.stub().returns({
+            annual: false,
+          }),
+        }),
       },
     })
 
@@ -1099,4 +1138,138 @@ describe('SubscriptionController', function () {
       expect(result).to.deep.equal({ isPaused: false })
     })
   })
+
+  describe('previewAddonPurchase', function () {
+    beforeEach(function () {
+      this.req = new MockRequest()
+      this.req.params = { addOnCode: 'assistant' }
+      this.req.query = { purchaseReferrer: 'fake-referrer' }
+      this.res = new MockResponse()
+
+      this.Modules.promises.hooks.fire
+        .withArgs('getPaymentMethod')
+        .resolves(['fake-method'])
+      this.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
+    })
+
+    describe('when user has manual or custom subscription', function () {
+      it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function () {
+        const customSubscription = {
+          _id: 'sub-123',
+          customAccount: true,
+          collectionMethod: 'automatic',
+        }
+        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          customSubscription
+        )
+
+        this.res.redirect = sinon.stub()
+
+        await this.SubscriptionController.previewAddonPurchase(
+          this.req,
+          this.res
+        )
+
+        expect(this.res.redirect).to.have.been.calledWith(
+          '/user/subscription?redirect-reason=ai-assist-unavailable'
+        )
+      })
+
+      it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function () {
+        const manualSubscription = {
+          _id: 'sub-123',
+          customAccount: false,
+          collectionMethod: 'manual',
+        }
+        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          manualSubscription
+        )
+
+        this.res.redirect = sinon.stub()
+
+        await this.SubscriptionController.previewAddonPurchase(
+          this.req,
+          this.res
+        )
+
+        expect(this.res.redirect).to.have.been.calledWith(
+          '/user/subscription?redirect-reason=ai-assist-unavailable'
+        )
+      })
+
+      it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function () {
+        const customManualSubscription = {
+          _id: 'sub-123',
+          customAccount: true,
+          collectionMethod: 'manual',
+        }
+        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          customManualSubscription
+        )
+
+        this.res.redirect = sinon.stub()
+
+        await this.SubscriptionController.previewAddonPurchase(
+          this.req,
+          this.res
+        )
+
+        expect(this.res.redirect).to.have.been.calledWith(
+          '/user/subscription?redirect-reason=ai-assist-unavailable'
+        )
+      })
+    })
+
+    describe('when user has normal subscription', function () {
+      it('should proceed with preview when subscription is not manual or custom', async function () {
+        const normalSubscription = {
+          _id: 'sub-123',
+          customAccount: false,
+          collectionMethod: 'automatic',
+        }
+        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          normalSubscription
+        )
+
+        this.res.render = sinon.stub()
+
+        await this.SubscriptionController.previewAddonPurchase(
+          this.req,
+          this.res
+        )
+
+        expect(this.res.render).to.have.been.calledWith(
+          'subscriptions/preview-change'
+        )
+        expect(
+          this.SubscriptionHandler.promises.previewAddonPurchase
+        ).to.have.been.calledWith(this.user._id, 'assistant')
+      })
+
+      it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function () {
+        const normalSubscription = {
+          _id: 'sub-123',
+          // customAccount: undefined (not set)
+          collectionMethod: 'automatic',
+        }
+        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          normalSubscription
+        )
+
+        this.res.render = sinon.stub()
+
+        await this.SubscriptionController.previewAddonPurchase(
+          this.req,
+          this.res
+        )
+
+        expect(this.res.render).to.have.been.calledWith(
+          'subscriptions/preview-change'
+        )
+        expect(
+          this.SubscriptionHandler.promises.previewAddonPurchase
+        ).to.have.been.calledWith(this.user._id, 'assistant')
+      })
+    })
+  })
 })