From d388e48a994c349c35e06be218e6af707ecb2f43 Mon Sep 17 00:00:00 2001 From: Olzhas Askar Date: Fri, 15 May 2026 10:44:48 +0200 Subject: [PATCH] Merge pull request #33679 from overleaf/oa-plan-names [web] Get plan names from the settings GitOrigin-RevId: 1e61975c3306c025f33e05686f9d2b57964b4f65 --- .../Subscription/SubscriptionController.mjs | 22 +-- .../SubscriptionController.test.mjs | 182 ++++++++++++++++-- 2 files changed, 178 insertions(+), 26 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.mjs b/services/web/app/src/Features/Subscription/SubscriptionController.mjs index 0a1dd580f9..6066e99ef4 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionController.mjs @@ -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, diff --git a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs index 6358fce45b..449663d1a9 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs @@ -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',