Просмотр исходного кода

Merge pull request #23415 from overleaf/ii-flexible-group-licensing-manually-collected

[web] Manually collected subscriptions with flexible licensing

GitOrigin-RevId: ca7cf2abf5cfa1d873614bf3407fb5a259a78a76
Liangjun Song 1 год назад
Родитель
Сommit
8421bcc5d2

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

@@ -16,9 +16,12 @@ class AddOnNotPresentError extends OError {}
 
 class MissingBillingInfoError extends OError {}
 
+class ManuallyCollectedError extends OError {}
+
 module.exports = {
   RecurlyTransactionError,
   DuplicateAddOnError,
   AddOnNotPresentError,
   MissingBillingInfoError,
+  ManuallyCollectedError,
 }

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

@@ -261,7 +261,8 @@ function subscriptionFromApi(apiSubscription) {
     apiSubscription.currency == null ||
     apiSubscription.currentPeriodStartedAt == null ||
     apiSubscription.currentPeriodEndsAt == null ||
-    apiSubscription.createdAt == null
+    apiSubscription.createdAt == null ||
+    apiSubscription.collectionMethod == null
   ) {
     throw new OError('Invalid Recurly subscription', {
       subscription: apiSubscription,
@@ -283,6 +284,7 @@ function subscriptionFromApi(apiSubscription) {
     periodStart: apiSubscription.currentPeriodStartedAt,
     periodEnd: apiSubscription.currentPeriodEndsAt,
     createdAt: apiSubscription.createdAt,
+    collectionMethod: apiSubscription.collectionMethod,
   })
 
   if (apiSubscription.pendingChange != null) {

+ 11 - 0
services/web/app/src/Features/Subscription/RecurlyEntities.js

@@ -26,6 +26,7 @@ class RecurlySubscription {
    * @param {Date} props.periodStart
    * @param {Date} props.periodEnd
    * @param {Date} props.createdAt
+   * @param {string} props.collectionMethod
    * @param {RecurlySubscriptionChange} [props.pendingChange]
    */
   constructor(props) {
@@ -43,6 +44,7 @@ class RecurlySubscription {
     this.periodStart = props.periodStart
     this.periodEnd = props.periodEnd
     this.createdAt = props.createdAt
+    this.collectionMethod = props.collectionMethod
     this.pendingChange = props.pendingChange ?? null
   }
 
@@ -246,6 +248,15 @@ class RecurlySubscription {
       planCode: newPlanCode,
     })
   }
+
+  /**
+   * Returns whether this subscription is manually collected
+   *
+   * @return {boolean}
+   */
+  get isCollectionMethodManual() {
+    return this.collectionMethod === 'manual'
+  }
 }
 
 /**

+ 36 - 2
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -13,7 +13,7 @@ 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 { MissingBillingInfoError, ManuallyCollectedError } from './Errors.js'
 import RecurlyClient from './RecurlyClient.js'
 
 /**
@@ -126,11 +126,14 @@ async function _removeUserFromGroup(
 async function addSeatsToGroupSubscription(req, res) {
   try {
     const userId = SessionManager.getLoggedInUserId(req.session)
-    const { subscription, plan } =
+    const { subscription, recurlySubscription, plan } =
       await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
         userId
       )
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
+    await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
+      recurlySubscription
+    )
     // Check if the user has missing billing details
     await RecurlyClient.promises.getPaymentMethod(userId)
     await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
@@ -155,6 +158,12 @@ async function addSeatsToGroupSubscription(req, res) {
       )
     }
 
+    if (error instanceof ManuallyCollectedError) {
+      return res.redirect(
+        '/user/subscription/group/manually-collected-subscription'
+      )
+    }
+
     return res.redirect('/user/subscription')
   }
 }
@@ -268,6 +277,12 @@ async function subscriptionUpgradePage(req, res) {
       )
     }
 
+    if (error instanceof ManuallyCollectedError) {
+      return res.redirect(
+        '/user/subscription/group/manually-collected-subscription'
+      )
+    }
+
     return res.redirect('/user/subscription')
   }
 }
@@ -301,6 +316,24 @@ async function missingBillingInformation(req, res) {
   }
 }
 
+async function manuallyCollectedSubscription(req, res) {
+  try {
+    const userId = SessionManager.getLoggedInUserId(req.session)
+    const subscription =
+      await SubscriptionLocator.promises.getUsersSubscription(userId)
+
+    res.render('subscriptions/manually-collected-subscription', {
+      groupName: subscription.teamName,
+    })
+  } catch (error) {
+    logger.err(
+      { error },
+      'error trying to render manually collected subscription page'
+    )
+    return res.render('/user/subscription')
+  }
+}
+
 export default {
   removeUserFromGroup: expressify(removeUserFromGroup),
   removeSelfFromGroup: expressify(removeSelfFromGroup),
@@ -316,4 +349,5 @@ export default {
   subscriptionUpgradePage: expressify(subscriptionUpgradePage),
   upgradeSubscription: expressify(upgradeSubscription),
   missingBillingInformation: expressify(missingBillingInformation),
+  manuallyCollectedSubscription: expressify(manuallyCollectedSubscription),
 }

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

@@ -8,6 +8,7 @@ const PlansLocator = require('./PlansLocator')
 const SubscriptionHandler = require('./SubscriptionHandler')
 const GroupPlansData = require('./GroupPlansData')
 const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./RecurlyEntities')
+const { ManuallyCollectedError } = require('./Errors')
 
 async function removeUserFromGroup(subscriptionId, userIdToRemove) {
   await SubscriptionUpdater.promises.removeUserFromGroup(
@@ -68,6 +69,19 @@ async function ensureSubscriptionIsActive(subscription) {
   }
 }
 
+async function ensureSubscriptionCollectionMethodIsNotManual(
+  recurlySubscription
+) {
+  if (recurlySubscription.isCollectionMethodManual) {
+    throw new ManuallyCollectedError(
+      'This subscription is being collected manually',
+      {
+        recurlySubscription_id: recurlySubscription.id,
+      }
+    )
+  }
+}
+
 async function getUsersGroupSubscriptionDetails(userId) {
   const subscription =
     await SubscriptionLocator.promises.getUsersSubscription(userId)
@@ -99,6 +113,8 @@ async function _addSeatsSubscriptionChange(userId, adding) {
     await getUsersGroupSubscriptionDetails(userId)
   await ensureFlexibleLicensingEnabled(plan)
   await ensureSubscriptionIsActive(subscription)
+  await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription)
+
   const currentAddonQuantity =
     recurlySubscription.addOns.find(
       addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE
@@ -207,6 +223,8 @@ async function _getGroupPlanUpgradeChangeRequest(ownerId) {
     olSubscription.recurlySubscription_id
   )
 
+  await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription)
+
   return recurlySubscription.getRequestForGroupPlanUpgrade(newPlanCode)
 }
 
@@ -244,6 +262,9 @@ module.exports = {
   replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
   ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
   ensureSubscriptionIsActive: callbackify(ensureSubscriptionIsActive),
+  ensureSubscriptionCollectionMethodIsNotManual: callbackify(
+    ensureSubscriptionCollectionMethodIsNotManual
+  ),
   getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
@@ -253,6 +274,7 @@ module.exports = {
     replaceUserReferencesInGroups,
     ensureFlexibleLicensingEnabled,
     ensureSubscriptionIsActive,
+    ensureSubscriptionCollectionMethodIsNotManual,
     getTotalConfirmedUsersInGroup,
     isUserPartOfGroup,
     getUsersGroupSubscriptionDetails,

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

@@ -127,6 +127,14 @@ export default {
       SubscriptionGroupController.missingBillingInformation
     )
 
+    webRouter.get(
+      '/user/subscription/group/manually-collected-subscription',
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionGroupController.flexibleLicensingSplitTest,
+      SubscriptionGroupController.manuallyCollectedSubscription
+    )
+
     // Team invites
     webRouter.get(
       '/subscription/invites/:token/',

+ 13 - 0
services/web/app/views/subscriptions/manually-collected-subscription.pug

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

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

@@ -45,6 +45,7 @@
   "access_denied": "",
   "access_edit_your_projects": "",
   "access_levels_changed": "",
+  "account_billed_manually": "",
   "account_has_been_link_to_institution_account": "",
   "account_has_past_due_invoice_change_plan_warning": "",
   "account_managed_by_group_administrator": "",
@@ -804,6 +805,7 @@
   "is_email_affiliated": "",
   "issued_on": "",
   "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
+  "it_looks_like_your_account_is_billed_manually": "",
   "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "",
   "join_beta_program": "",
   "join_now": "",

+ 43 - 0
services/web/frontend/js/features/group-management/components/card.tsx

@@ -0,0 +1,43 @@
+import { useTranslation } from 'react-i18next'
+import getMeta from '@/utils/meta'
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import { Card as BSCard, CardBody, Col, Row } from 'react-bootstrap-5'
+import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
+
+type CardProps = {
+  children: React.ReactNode
+}
+
+function Card({ children }: CardProps) {
+  const { t } = useTranslation()
+  const groupName = getMeta('ol-groupName')
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
+
+  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>
+          <BSCard>
+            <CardBody>{children}</CardBody>
+          </BSCard>
+        </Col>
+      </Row>
+    </div>
+  )
+}
+
+export default Card

+ 28 - 0
services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx

@@ -0,0 +1,28 @@
+import { Trans, useTranslation } from 'react-i18next'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+import Card from '@/features/group-management/components/card'
+
+function ManuallyCollectedSubscription() {
+  const { t } = useTranslation()
+
+  return (
+    <Card>
+      <OLNotification
+        type="error"
+        title={t('account_billed_manually')}
+        content={
+          <Trans
+            i18nKey="it_looks_like_your_account_is_billed_manually"
+            components={[
+              // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+              <a href="/contact" rel="noreferrer noopener" />,
+            ]}
+          />
+        }
+        className="m-0"
+      />
+    </Card>
+  )
+}
+
+export default ManuallyCollectedSubscription

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

@@ -1,50 +1,29 @@
 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'
+import Card from '@/features/group-management/components/card'
 
 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>
+    <Card>
+      <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"
+      />
+    </Card>
   )
 }
 

+ 8 - 0
services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx

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

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

@@ -51,6 +51,7 @@
   "access_edit_your_projects": "Access and edit your projects",
   "access_levels_changed": "Access levels changed",
   "account": "Account",
+  "account_billed_manually": "Account billed manually",
   "account_has_been_link_to_institution_account": "Your __appName__ account on <b>__email__</b> has been linked to your <b>__institutionName__</b> institutional account.",
   "account_has_past_due_invoice_change_plan_warning": "Your account currently has a past due invoice. You will not be able to change your plan until this is resolved.",
   "account_linking": "Account Linking",
@@ -1061,6 +1062,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_account_is_billed_manually": "It looks like your account is being billed manually - adding seats or upgrading your subscription can only be done by the Support team. Please <0>get in touch</0> for 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",

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

@@ -51,6 +51,7 @@ describe('RecurlyClient', function () {
       periodStart: new Date(),
       periodEnd: new Date(),
       createdAt: new Date(),
+      collectionMethod: 'automatic',
     })
 
     this.recurlySubscription = {
@@ -81,6 +82,7 @@ describe('RecurlyClient', function () {
       currentPeriodStartedAt: this.subscription.periodStart,
       currentPeriodEndsAt: this.subscription.periodEnd,
       createdAt: this.subscription.createdAt,
+      collectionMethod: this.subscription.collectionMethod,
     }
 
     this.recurlySubscriptionChange = new recurly.SubscriptionChange()

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

@@ -373,6 +373,7 @@ describe('RecurlyEntities', function () {
           periodStart: new Date(),
           periodEnd: new Date(),
           createdAt: new Date(),
+          collectionMethod: 'automatic',
         })
         const change = new RecurlySubscriptionChange({
           subscription,

+ 40 - 2
services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs

@@ -57,6 +57,7 @@ describe('SubscriptionGroupController', function () {
           .resolves(this.createSubscriptionChangeData),
         ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
         ensureSubscriptionIsActive: sinon.stub().resolves(),
+        ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(),
         getGroupPlanUpgradePreview: sinon
           .stub()
           .resolves(this.previewSubscriptionChangeData),
@@ -109,6 +110,7 @@ describe('SubscriptionGroupController', function () {
     this.RecurlyClient = {
       promises: {
         getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
+        // getSubscription: sinon.stub().resolves(this.subscription),
       },
     }
 
@@ -122,6 +124,7 @@ describe('SubscriptionGroupController', function () {
 
     this.Errors = {
       MissingBillingInfoError: class MissingBillingInfoError extends Error {},
+      ManuallyCollectedError: class ManuallyCollectedError extends Error {},
     }
 
     this.Controller = await esmock.strict(modulePath, {
@@ -408,6 +411,22 @@ describe('SubscriptionGroupController', function () {
       this.Controller.addSeatsToGroupSubscription(this.req, res)
     })
 
+    it('should redirect to manually collected subscription error page when collection method is manual', function (done) {
+      this.SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual =
+        sinon.stub().throws(new this.Errors.ManuallyCollectedError())
+
+      const res = {
+        redirect: url => {
+          url.should.equal(
+            '/user/subscription/group/manually-collected-subscription'
+          )
+          done()
+        },
+      }
+
+      this.Controller.addSeatsToGroupSubscription(this.req, res)
+    })
+
     it('should redirect to subscription page when subscription is not active', function (done) {
       this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon
         .stub()
@@ -598,13 +617,32 @@ describe('SubscriptionGroupController', function () {
     })
 
     it('should redirect to missing billing information page when billing information is missing', function (done) {
-      this.RecurlyClient.promises.getPaymentMethod = sinon
+      this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
         .stub()
         .throws(new this.Errors.MissingBillingInfoError())
 
       const res = {
         redirect: url => {
-          url.should.equal('/user/subscription')
+          url.should.equal(
+            '/user/subscription/group/missing-billing-information'
+          )
+          done()
+        },
+      }
+
+      this.Controller.subscriptionUpgradePage(this.req, res)
+    })
+
+    it('should redirect to manually collected subscription error page when collection method is manual', function (done) {
+      this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
+        .stub()
+        .throws(new this.Errors.ManuallyCollectedError())
+
+      const res = {
+        redirect: url => {
+          url.should.equal(
+            '/user/subscription/group/manually-collected-subscription'
+          )
           done()
         },
       }

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

@@ -543,6 +543,28 @@ describe('SubscriptionGroupHandler', function () {
     })
   })
 
+  describe('ensureSubscriptionCollectionMethodIsNotManual', function () {
+    it('should throw if the subscription is manually collected', async function () {
+      await expect(
+        this.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
+          get isCollectionMethodManual() {
+            return true
+          },
+        })
+      ).to.be.rejectedWith('This subscription is being collected manually')
+    })
+
+    it('should not throw if the subscription is automatically collected', async function () {
+      await expect(
+        this.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
+          get isCollectionMethodManual() {
+            return false
+          },
+        })
+      ).to.not.be.rejected
+    })
+  })
+
   describe('upgradeGroupPlan', function () {
     it('should upgrade the subscription for flexible licensing group plans', async function () {
       this.SubscriptionLocator.promises.getUsersSubscription = sinon