Explorar el Código

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

[web] Get plan names from the settings

GitOrigin-RevId: 1e61975c3306c025f33e05686f9d2b57964b4f65
Olzhas Askar hace 3 meses
padre
commit
d388e48a99

+ 11 - 11
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,

+ 167 - 15
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',