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
This commit is contained in:
Liangjun Song
2025-08-27 15:35:54 +01:00
committed by Copybot
parent 3adf77994b
commit 9a7bc564c1
2 changed files with 220 additions and 5 deletions

View File

@@ -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 {

View File

@@ -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')
})
})
})
})