فهرست منبع

Merge pull request #23203 from overleaf/ii-flexible-group-licensing-no-billing-details

[web] FL handle subscriptions with missing billing info

GitOrigin-RevId: 34209299c039992a80da5739e086beb5d0ede7b0
ilkin-overleaf 1 سال پیش
والد
کامیت
16130b79db

+ 3 - 0
services/web/app/src/Features/Subscription/Errors.js

@@ -14,8 +14,11 @@ class DuplicateAddOnError extends OError {}
 
 class AddOnNotPresentError extends OError {}
 
+class MissingBillingInfoError extends OError {}
+
 module.exports = {
   RecurlyTransactionError,
   DuplicateAddOnError,
   AddOnNotPresentError,
+  MissingBillingInfoError,
 }

+ 14 - 1
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -16,6 +16,7 @@ const {
   RecurlyPlan,
   RecurlyImmediateCharge,
 } = require('./RecurlyEntities')
+const { MissingBillingInfoError } = require('./Errors')
 
 /**
  * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
@@ -193,7 +194,19 @@ async function resumeSubscriptionByUuid(subscriptionUuid) {
  * @return {Promise<PaymentMethod>}
  */
 async function getPaymentMethod(userId) {
-  const billingInfo = await client.getBillingInfo(`code-${userId}`)
+  let billingInfo
+
+  try {
+    billingInfo = await client.getBillingInfo(`code-${userId}`)
+  } catch (error) {
+    if (error instanceof recurly.errors.NotFoundError) {
+      throw new MissingBillingInfoError('This account has no billing info', {
+        userId,
+      })
+    }
+    throw error
+  }
+
   return paymentMethodFromApi(billingInfo)
 }
 

+ 37 - 0
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -13,6 +13,8 @@ import ErrorController from '../Errors/ErrorController.js'
 import UserGetter from '../User/UserGetter.js'
 import { Subscription } from '../../models/Subscription.js'
 import { isProfessionalGroupPlan } from './PlansHelper.mjs'
+import { MissingBillingInfoError } from './Errors.js'
+import RecurlyClient from './RecurlyClient.js'
 
 /**
  * @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js"
@@ -129,6 +131,8 @@ async function addSeatsToGroupSubscription(req, res) {
         userId
       )
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
+    // Check if the user has missing billing details
+    await RecurlyClient.promises.getPaymentMethod(userId)
 
     res.render('subscriptions/add-seats', {
       subscriptionId: subscription._id,
@@ -141,6 +145,13 @@ async function addSeatsToGroupSubscription(req, res) {
       { error },
       'error while getting users group subscription details'
     )
+
+    if (error instanceof MissingBillingInfoError) {
+      return res.redirect(
+        '/user/subscription/group/missing-billing-information'
+      )
+    }
+
     return res.redirect('/user/subscription')
   }
 }
@@ -247,6 +258,13 @@ async function subscriptionUpgradePage(req, res) {
     })
   } catch (error) {
     logger.err({ error }, 'error loading upgrade subscription page')
+
+    if (error instanceof MissingBillingInfoError) {
+      return res.redirect(
+        '/user/subscription/group/missing-billing-information'
+      )
+    }
+
     return res.redirect('/user/subscription')
   }
 }
@@ -262,6 +280,24 @@ async function upgradeSubscription(req, res) {
   }
 }
 
+async function missingBillingInformation(req, res) {
+  try {
+    const userId = SessionManager.getLoggedInUserId(req.session)
+    const subscription =
+      await SubscriptionLocator.promises.getUsersSubscription(userId)
+
+    res.render('subscriptions/missing-billing-information', {
+      groupName: subscription.teamName,
+    })
+  } catch (error) {
+    logger.err(
+      { error },
+      'error trying to render missing billing information page'
+    )
+    return res.render('/user/subscription')
+  }
+}
+
 export default {
   removeUserFromGroup: expressify(removeUserFromGroup),
   removeSelfFromGroup: expressify(removeSelfFromGroup),
@@ -276,4 +312,5 @@ export default {
   ),
   subscriptionUpgradePage: expressify(subscriptionUpgradePage),
   upgradeSubscription: expressify(upgradeSubscription),
+  missingBillingInformation: expressify(missingBillingInformation),
 }

+ 1 - 0
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js

@@ -81,6 +81,7 @@ async function getUsersGroupSubscriptionDetails(userId) {
   )
 
   return {
+    userId,
     subscription,
     recurlySubscription,
     plan,

+ 8 - 0
services/web/app/src/Features/Subscription/SubscriptionRouter.mjs

@@ -119,6 +119,14 @@ export default {
       SubscriptionGroupController.upgradeSubscription
     )
 
+    webRouter.get(
+      '/user/subscription/group/missing-billing-information',
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionGroupController.flexibleLicensingSplitTest,
+      SubscriptionGroupController.missingBillingInformation
+    )
+
     // Team invites
     webRouter.get(
       '/subscription/invites/:token/',

+ 13 - 0
services/web/app/views/subscriptions/missing-billing-information.pug

@@ -0,0 +1,13 @@
+extends ../layout-marketing
+
+block vars
+	- bootstrap5PageStatus = 'enabled' // Enforce BS5 version
+
+block entrypointVar
+	- entrypoint = 'pages/user/subscription/group-management/missing-billing-information'
+
+block append meta
+	meta(name="ol-groupName", data-type="string", content=groupName)
+
+block content
+	main.content.content-alt#missing-billing-information-root

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -794,6 +794,7 @@
   "is_email_affiliated": "",
   "issued_on": "",
   "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
+  "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "",
   "join_beta_program": "",
   "join_now": "",
   "join_overleaf_labs": "",
@@ -951,6 +952,7 @@
   "message_received": "",
   "missing_field_for_entry": "",
   "missing_fields_for_entry": "",
+  "missing_payment_details": "",
   "money_back_guarantee": "",
   "month": "",
   "month_plural": "",

+ 51 - 0
services/web/frontend/js/features/group-management/components/missing-billing-information.tsx

@@ -0,0 +1,51 @@
+import { Trans, useTranslation } from 'react-i18next'
+import { Card, CardBody, Row, Col } from 'react-bootstrap-5'
+import getMeta from '@/utils/meta'
+import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+
+function MissingBillingInformation() {
+  const { t } = useTranslation()
+  const groupName = getMeta('ol-groupName')
+
+  return (
+    <div className="container">
+      <Row>
+        <Col xl={{ span: 4, offset: 4 }} md={{ span: 6, offset: 3 }}>
+          <div className="group-heading" data-testid="group-heading">
+            <IconButton
+              variant="ghost"
+              href="/user/subscription"
+              size="lg"
+              icon="arrow_back"
+              accessibilityLabel={t('back_to_subscription')}
+            />
+            <h2>{groupName || t('group_subscription')}</h2>
+          </div>
+          <Card>
+            <CardBody>
+              <OLNotification
+                type="error"
+                title={t('missing_payment_details')}
+                content={
+                  <Trans
+                    i18nKey="it_looks_like_your_payment_details_are_missing_please_update_your_billing_information"
+                    components={[
+                      // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                      <a href="/user/subscription" rel="noreferrer noopener" />,
+                      // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                      <a href="/contact" rel="noreferrer noopener" />,
+                    ]}
+                  />
+                }
+                className="m-0"
+              />
+            </CardBody>
+          </Card>
+        </Col>
+      </Row>
+    </div>
+  )
+}
+
+export default MissingBillingInformation

+ 8 - 0
services/web/frontend/js/pages/user/subscription/group-management/missing-billing-information.tsx

@@ -0,0 +1,8 @@
+import '../base'
+import ReactDOM from 'react-dom'
+import MissingBillingInformation from '@/features/group-management/components/missing-billing-information'
+
+const element = document.getElementById('missing-billing-information-root')
+if (element) {
+  ReactDOM.render(<MissingBillingInformation />, element)
+}

+ 2 - 0
services/web/locales/en.json

@@ -1052,6 +1052,7 @@
   "issued_on": "Issued: __date__",
   "it": "Italian",
   "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch</0> with our Support team for more help.",
+  "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "It looks like your payment details are missing. Please <0>update your billing information</0>, or <1>get in touch</1> with our Support team for more help.",
   "ja": "Japanese",
   "january": "January",
   "join_beta_program": "Join beta program",
@@ -1270,6 +1271,7 @@
   "message_received": "Message received",
   "missing_field_for_entry": "Missing field for",
   "missing_fields_for_entry": "Missing fields for",
+  "missing_payment_details": "Missing payment details",
   "money_back_guarantee": "30-day money back guarantee, no questions asked",
   "month": "month",
   "month_plural": "months",

+ 35 - 0
services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx

@@ -0,0 +1,35 @@
+import '../../../helpers/bootstrap-5'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
+import MissingBillingInformation from '@/features/group-management/components/missing-billing-information'
+
+describe('<MissingBillingInformation />', function () {
+  beforeEach(function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
+    })
+
+    cy.mount(
+      <SplitTestProvider>
+        <MissingBillingInformation />
+      </SplitTestProvider>
+    )
+  })
+
+  it('shows missing payment details notification', function () {
+    cy.findByRole('alert').within(() => {
+      cy.findByText(/missing payment details/i)
+      cy.findByText(
+        /it looks like your payment details are missing\. Please.*, or.*with our Support team for more help/i
+      ).within(() => {
+        cy.findByRole('link', {
+          name: /update your billing information/i,
+        }).should('have.attr', 'href', '/user/subscription')
+        cy.findByRole('link', { name: /get in touch/i }).should(
+          'have.attr',
+          'href',
+          '/contact'
+        )
+      })
+    })
+  })
+})

+ 23 - 0
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -99,6 +99,7 @@ describe('RecurlyClient', function () {
     let client
     this.client = client = {
       getAccount: sinon.stub(),
+      getBillingInfo: sinon.stub(),
       listAccountSubscriptions: sinon.stub(),
       previewSubscriptionChange: sinon.stub(),
     }
@@ -108,6 +109,9 @@ describe('RecurlyClient', function () {
         return client
       },
     }
+    this.Errors = {
+      MissingBillingInfoError: class MissingBillingInfoError extends Error {},
+    }
 
     return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, {
       globals: {
@@ -124,6 +128,7 @@ describe('RecurlyClient', function () {
           debug: sinon.stub(),
         },
         '../User/UserGetter': this.UserGetter,
+        './Errors': this.Errors,
       },
     }))
   })
@@ -463,4 +468,22 @@ describe('RecurlyClient', function () {
       })
     })
   })
+
+  describe('getPaymentMethod', function () {
+    it('should throw MissingBillingInfoError', async function () {
+      this.client.getBillingInfo = sinon
+        .stub()
+        .throws(new recurly.errors.NotFoundError())
+      await expect(
+        this.RecurlyClient.promises.getPaymentMethod(this.user._id)
+      ).to.be.rejectedWith(this.Errors.MissingBillingInfoError)
+    })
+
+    it('should rethrow errors different than MissingBillingInfoError', async function () {
+      this.client.getBillingInfo = sinon.stub().throws(new Error())
+      await expect(
+        this.RecurlyClient.promises.getPaymentMethod(this.user._id)
+      ).to.be.rejectedWith(Error)
+    })
+  })
 })

+ 44 - 1
services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs

@@ -103,7 +103,13 @@ describe('SubscriptionGroupController', function () {
       },
     }
 
-    this.RecurlyClient = {}
+    this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
+
+    this.RecurlyClient = {
+      promises: {
+        getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
+      },
+    }
 
     this.SubscriptionController = {}
 
@@ -113,6 +119,10 @@ describe('SubscriptionGroupController', function () {
       isProfessionalGroupPlan: sinon.stub().returns(false),
     }
 
+    this.Errors = {
+      MissingBillingInfoError: class MissingBillingInfoError extends Error {},
+    }
+
     this.Controller = await esmock.strict(modulePath, {
       '../../../../app/src/Features/Subscription/SubscriptionGroupHandler':
         this.SubscriptionGroupHandler,
@@ -135,6 +145,7 @@ describe('SubscriptionGroupController', function () {
       '../../../../app/src/Features/Subscription/RecurlyClient':
         this.RecurlyClient,
       '../../../../app/src/Features/Subscription/PlansHelper': this.PlansHelper,
+      '../../../../app/src/Features/Subscription/Errors': this.Errors,
       '../../../../app/src/models/Subscription': this.SubscriptionModel,
       '@overleaf/logger': {
         err: sinon.stub(),
@@ -375,6 +386,23 @@ describe('SubscriptionGroupController', function () {
 
       this.Controller.addSeatsToGroupSubscription(this.req, res)
     })
+
+    it('should redirect to missing billing information page when billing information is missing', function (done) {
+      this.RecurlyClient.promises.getPaymentMethod = sinon
+        .stub()
+        .throws(new this.Errors.MissingBillingInfoError())
+
+      const res = {
+        redirect: url => {
+          url.should.equal(
+            '/user/subscription/group/missing-billing-information'
+          )
+          done()
+        },
+      }
+
+      this.Controller.addSeatsToGroupSubscription(this.req, res)
+    })
   })
 
   describe('previewAddSeatsSubscriptionChange', function () {
@@ -549,6 +577,21 @@ describe('SubscriptionGroupController', function () {
 
       this.Controller.subscriptionUpgradePage(this.req, res)
     })
+
+    it('should redirect to missing billing information page when billing information is missing', function (done) {
+      this.RecurlyClient.promises.getPaymentMethod = sinon
+        .stub()
+        .throws(new this.Errors.MissingBillingInfoError())
+
+      const res = {
+        redirect: url => {
+          url.should.equal('/user/subscription')
+          done()
+        },
+      }
+
+      this.Controller.subscriptionUpgradePage(this.req, res)
+    })
   })
 
   describe('upgradeSubscription', function () {

+ 1 - 0
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -302,6 +302,7 @@ describe('SubscriptionGroupHandler', function () {
       )
 
       expect(data).to.deep.equal({
+        userId: this.adminUser_id,
         subscription: { groupPlan: true },
         plan: {
           membersLimit: 5,