Merge pull request #33679 from overleaf/oa-plan-names

[web] Get plan names from the settings

GitOrigin-RevId: 1e61975c3306c025f33e05686f9d2b57964b4f65
This commit is contained in:
Olzhas Askar
2026-05-15 10:44:48 +02:00
committed by Copybot
parent 6c267e68d3
commit d388e48a99
2 changed files with 178 additions and 26 deletions

View File

@@ -22,7 +22,6 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
import Modules from '../../infrastructure/Modules.mjs'
import async from 'async'
import HttpErrorHandler from '../Errors/HttpErrorHandler.mjs'
import RecurlyClient from './RecurlyClient.mjs'
import {
AI_ADD_ON_CODE,
subscriptionChangeIsAiAssistUpgrade,
@@ -628,18 +627,17 @@ async function previewAddonPurchase(req, res) {
throw err
}
const subscription = subscriptionChange.subscription
const addOn = await RecurlyClient.promises.getAddOn(
subscription.planCode,
addOnCode
)
const addOn = PlansLocator.findLocalPlanInSettings(addOnCode)
if (!addOn) {
return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
}
/** @type {SubscriptionChangePreview} */
const changePreview = makeChangePreview(
{
type: 'add-on-purchase',
addOn: {
code: addOn.code,
code: addOn.planCode,
name: addOn.name,
},
},
@@ -868,8 +866,10 @@ async function previewSubscription(req, res, next) {
if (!planCode) {
return HttpErrorHandler.notFound(req, res, 'Missing plan code')
}
// TODO: use PaymentService to fetch plan information
const plan = await RecurlyClient.promises.getPlan(planCode)
const plan = PlansLocator.findLocalPlanInSettings(planCode)
if (!plan) {
return HttpErrorHandler.notFound(req, res, `Unknown plan: ${planCode}`)
}
const user = SessionManager.getSessionUser(req.session)
const userId = user?._id
@@ -896,7 +896,7 @@ async function previewSubscription(req, res, next) {
const changePreview = makeChangePreview(
{
type: 'premium-subscription',
plan: { code: plan.code, name: plan.name },
plan: { code: plan.planCode, name: plan.name },
},
subscriptionChange,
paymentMethod[0]
@@ -1277,7 +1277,7 @@ function makeChangePreview(
date: subscription.periodEnd.toISOString(),
plan: {
name: getPlanNameForDisplay(
futureInvoiceChange.nextPlanName,
nextPlan?.name ?? futureInvoiceChange.nextPlanName,
futureInvoiceChange.nextPlanCode
),
amount: futureInvoiceChange.nextPlanPrice,

View File

@@ -287,6 +287,10 @@ describe('SubscriptionController', function () {
res.status(403)
res.json({ message })
}),
notFound: sinon.stub().callsFake((req, res, message) => {
res.status(404)
res.json({ message })
}),
}),
}))
@@ -350,20 +354,6 @@ describe('SubscriptionController', function () {
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyClient',
() => ({
default: (ctx.RecurlyClient = {
promises: {
getAddOn: sinon.stub().resolves({
code: 'ai-assistant',
name: 'AI Assistant',
}),
},
}),
})
)
vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
default: (ctx.PlansLocator = {
findLocalPlanInSettings: sinon.stub().returns({
@@ -1538,6 +1528,130 @@ describe('SubscriptionController', function () {
expect(preview.nextInvoice.plan.name).to.equal('Professional')
expect(preview.nextInvoice.plan.amount).to.equal(2000)
})
it('prefers the local plan name over the legacy payment-provider name for the future invoice', function (ctx) {
baseSubscription.pendingChange = undefined
ctx.PlansLocator.findLocalPlanInSettings
.withArgs('professional')
.returns({
planCode: 'professional',
name: 'Pro monthly',
annual: false,
})
const preview = ctx.SubscriptionController.makeChangePreview(
{
type: 'premium-subscription',
plan: { code: 'professional', name: 'Pro monthly' },
},
subscriptionChange
)
expect(preview.nextInvoice.plan.name).to.equal('Pro monthly')
})
})
describe('previewSubscription', function () {
beforeEach(function (ctx) {
ctx.req = new MockRequest(vi)
ctx.req.query = { planCode: 'collaborator' }
ctx.res = new MockResponse(vi)
ctx.res.render = sinon.stub()
ctx.PlansLocator.findLocalPlanInSettings.returns({
planCode: 'collaborator',
name: 'Standard monthly',
annual: false,
})
ctx.SubscriptionHandler.promises.previewSubscriptionChange = sinon
.stub()
.resolves({
subscription: {
currency: 'USD',
netTerms: 0,
periodEnd: new Date('2027-04-29'),
taxRate: 0,
},
nextPlanCode: 'collaborator',
nextPlanName: 'Standard monthly',
nextPlanPrice: 2300,
nextAddOns: [],
immediateCharge: { subtotal: 0, tax: 0, total: 0, discount: 0 },
subtotal: 2300,
tax: 0,
total: 2300,
})
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentMethod')
.resolves(['fake-method'])
})
it('renders the renamed local plan name in changePreview.change.plan', async function (ctx) {
await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change',
sinon.match({
changePreview: sinon.match({
change: {
type: 'premium-subscription',
plan: { code: 'collaborator', name: 'Standard monthly' },
},
}),
})
)
expect(ctx.PlansLocator.findLocalPlanInSettings).to.have.been.calledWith(
'collaborator'
)
})
it('returns 404 when planCode is missing', async function (ctx) {
ctx.req.query = {}
await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)
expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
ctx.req,
ctx.res,
'Missing plan code'
)
expect(ctx.res.render).not.to.have.been.called
})
it('returns 404 when planCode is unknown to the local plan registry', async function (ctx) {
ctx.req.query = { planCode: 'does-not-exist' }
ctx.PlansLocator.findLocalPlanInSettings.returns(null)
await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)
expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
ctx.req,
ctx.res,
'Unknown plan: does-not-exist'
)
expect(ctx.res.render).not.to.have.been.called
})
it('passes trialDisabledReason to the view when the user is ineligible for a free trial', async function (ctx) {
ctx.req.query = { planCode: 'collaborator_free_trial_7_days' }
ctx.PlansLocator.findLocalPlanInSettings.returns({
planCode: 'collaborator_free_trial_7_days',
name: 'Standard monthly',
annual: false,
})
ctx.Modules.promises.hooks.fire
.withArgs('userCanStartTrial', ctx.user)
.resolves([{ canStartTrial: false, disabledReason: 'already-used' }])
await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change',
sinon.match({
trialDisabledReason: 'already-used',
})
)
})
})
describe('previewAddonPurchase', function () {
@@ -1635,6 +1749,11 @@ describe('SubscriptionController', function () {
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.PlansLocator.findLocalPlanInSettings.withArgs('assistant').returns({
planCode: 'assistant',
name: 'AI Assist',
annual: false,
})
ctx.res.render = sinon.stub()
@@ -1643,7 +1762,12 @@ describe('SubscriptionController', function () {
expect(ctx.res.render).to.have.been.calledWith(
'subscriptions/preview-change',
sinon.match({
changePreview: sinon.match.object,
changePreview: sinon.match({
change: {
type: 'add-on-purchase',
addOn: { code: 'assistant', name: 'AI Assist' },
},
}),
purchaseReferrer: 'fake-referrer',
redirectedPaymentErrorCode: undefined,
})
@@ -1651,6 +1775,9 @@ describe('SubscriptionController', function () {
expect(
ctx.SubscriptionHandler.promises.previewAddonPurchase
).to.have.been.calledWith(ctx.user._id, 'assistant')
expect(
ctx.PlansLocator.findLocalPlanInSettings
).to.have.been.calledWith('assistant')
})
it('should pass redirectedPaymentErrorCode to the view when errorCode query param is present', async function (ctx) {
@@ -1678,6 +1805,31 @@ describe('SubscriptionController', function () {
)
})
it('returns 404 when the add-on code is not in the local plan registry', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',
customAccount: false,
collectionMethod: 'automatic',
}
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
normalSubscription
)
ctx.PlansLocator.findLocalPlanInSettings
.withArgs('assistant')
.returns(null)
ctx.res.render = sinon.stub()
await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
ctx.req,
ctx.res,
'Unknown add-on: assistant'
)
expect(ctx.res.render).not.to.have.been.called
})
it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function (ctx) {
const normalSubscription = {
_id: 'sub-123',