From 9a7bc564c1ce978180d7739790287cae6634716e Mon Sep 17 00:00:00 2001 From: Liangjun Song <146005915+adai26@users.noreply.github.com> Date: Wed, 27 Aug 2025 15:35:54 +0100 Subject: [PATCH] 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 --- .../Subscription/SubscriptionController.js | 52 +++++- .../SubscriptionControllerTests.js | 173 ++++++++++++++++++ 2 files changed, 220 insertions(+), 5 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 65190e7774..1bfe614e81 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/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} + */ +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 { diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index b397adb21c..130f583b04 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/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') + }) + }) + }) })