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

Merge pull request #25318 from overleaf/ii-flexible-licensing-manually-collected-3

[web] Add seats feature for manually collected subscriptions improvements

GitOrigin-RevId: 4fbd93097590d97ad6464d1988471a78bf7cb9e2
ilkin-overleaf 1 год назад
Родитель
Сommit
2ccdb74d20
21 измененных файлов с 293 добавлено и 83 удалено
  1. 3 0
      services/web/app/src/Features/Subscription/Errors.js
  2. 2 0
      services/web/app/src/Features/Subscription/PaymentProviderEntities.js
  3. 2 21
      services/web/app/src/Features/Subscription/RecurlyClient.js
  4. 1 0
      services/web/app/src/Features/Subscription/SubscriptionController.js
  5. 19 3
      services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
  6. 23 2
      services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js
  7. 2 1
      services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
  8. 6 0
      services/web/frontend/extracted-translations.json
  9. 44 2
      services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx
  10. 17 4
      services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx
  11. 16 2
      services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx
  12. 28 7
      services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx
  13. 1 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
  14. 6 1
      services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx
  15. 5 0
      services/web/locales/en.json
  16. 69 7
      services/web/test/frontend/features/group-management/components/add-seats.spec.tsx
  17. 1 0
      services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js
  18. 2 25
      services/web/test/unit/src/Subscription/RecurlyClientTests.js
  19. 19 0
      services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
  20. 26 7
      services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
  21. 1 0
      services/web/types/subscription/subscription-change-preview.ts

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

@@ -24,6 +24,8 @@ class InactiveError extends OError {}
 
 class SubtotalLimitExceededError extends OError {}
 
+class HasPastDueInvoiceError extends OError {}
+
 module.exports = {
   RecurlyTransactionError,
   DuplicateAddOnError,
@@ -33,4 +35,5 @@ module.exports = {
   PendingChangeError,
   InactiveError,
   SubtotalLimitExceededError,
+  HasPastDueInvoiceError,
 }

+ 2 - 0
services/web/app/src/Features/Subscription/PaymentProviderEntities.js

@@ -30,6 +30,7 @@ class PaymentProviderSubscription {
    * @param {Date} props.periodStart
    * @param {Date} props.periodEnd
    * @param {string} props.collectionMethod
+   * @param {number} [props.netTerms]
    * @param {string} [props.poNumber]
    * @param {string} [props.termsAndConditions]
    * @param {PaymentProviderSubscriptionChange} [props.pendingChange]
@@ -55,6 +56,7 @@ class PaymentProviderSubscription {
     this.periodStart = props.periodStart
     this.periodEnd = props.periodEnd
     this.collectionMethod = props.collectionMethod
+    this.netTerms = props.netTerms ?? 0
     this.poNumber = props.poNumber ?? ''
     this.termsAndConditions = props.termsAndConditions ?? ''
     this.pendingChange = props.pendingChange ?? null

+ 2 - 21
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -384,25 +384,6 @@ async function getPlan(planCode) {
   return planFromApi(plan)
 }
 
-/**
- * Get the country code for given user
- *
- * @param {string} userId
- * @return {Promise<string>}
- */
-async function getCountryCode(userId) {
-  const account = await client.getAccount(`code-${userId}`)
-  const countryCode = account.address?.country
-
-  if (!countryCode) {
-    throw new OError('Country code not found', {
-      userId,
-    })
-  }
-
-  return countryCode
-}
-
 function subscriptionIsCanceledOrExpired(subscription) {
   const state = subscription?.recurlyStatus?.state
   return state === 'canceled' || state === 'expired'
@@ -467,6 +448,7 @@ function subscriptionFromApi(apiSubscription) {
     apiSubscription.currentPeriodStartedAt == null ||
     apiSubscription.currentPeriodEndsAt == null ||
     apiSubscription.collectionMethod == null ||
+    apiSubscription.netTerms == null ||
     // The values below could be null initially if the subscription has never updated
     !('poNumber' in apiSubscription) ||
     !('termsAndConditions' in apiSubscription)
@@ -491,6 +473,7 @@ function subscriptionFromApi(apiSubscription) {
     periodStart: apiSubscription.currentPeriodStartedAt,
     periodEnd: apiSubscription.currentPeriodEndsAt,
     collectionMethod: apiSubscription.collectionMethod,
+    netTerms: apiSubscription.netTerms ?? 0,
     poNumber: apiSubscription.poNumber ?? '',
     termsAndConditions: apiSubscription.termsAndConditions ?? '',
     service: 'recurly',
@@ -720,7 +703,6 @@ module.exports = {
   getPaymentMethod: callbackify(getPaymentMethod),
   getAddOn: callbackify(getAddOn),
   getPlan: callbackify(getPlan),
-  getCountryCode: callbackify(getCountryCode),
   subscriptionIsCanceledOrExpired,
   pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
   resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
@@ -744,6 +726,5 @@ module.exports = {
     getPaymentMethod,
     getAddOn,
     getPlan,
-    getCountryCode,
   },
 }

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

@@ -740,6 +740,7 @@ function makeChangePreview(
     currency: subscription.currency,
     immediateCharge: { ...subscriptionChange.immediateCharge },
     paymentMethod: paymentMethod?.toString(),
+    netTerms: subscription.netTerms,
     nextPlan: {
       annual: nextPlan.annual ?? false,
     },

+ 19 - 3
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -18,6 +18,7 @@ import {
   PendingChangeError,
   InactiveError,
   SubtotalLimitExceededError,
+  HasPastDueInvoiceError,
 } from './Errors.js'
 import RecurlyClient from './RecurlyClient.js'
 
@@ -142,6 +143,9 @@ async function addSeatsToGroupSubscription(req, res) {
     await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
       subscription
     )
+    await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice(
+      subscription
+    )
 
     const { variant: flexibleLicensingForManuallyBilledSubscriptionsVariant } =
       await SplitTestHandler.promises.getAssignment(
@@ -183,7 +187,11 @@ async function addSeatsToGroupSubscription(req, res) {
       )
     }
 
-    if (error instanceof PendingChangeError || error instanceof InactiveError) {
+    if (
+      error instanceof PendingChangeError ||
+      error instanceof InactiveError ||
+      error instanceof HasPastDueInvoiceError
+    ) {
       return res.redirect('/user/subscription')
     }
 
@@ -216,7 +224,8 @@ async function previewAddSeatsSubscriptionChange(req, res) {
       error instanceof MissingBillingInfoError ||
       error instanceof ManuallyCollectedError ||
       error instanceof PendingChangeError ||
-      error instanceof InactiveError
+      error instanceof InactiveError ||
+      error instanceof HasPastDueInvoiceError
     ) {
       return res.status(422).end()
     }
@@ -258,7 +267,8 @@ async function createAddSeatsSubscriptionChange(req, res) {
       error instanceof MissingBillingInfoError ||
       error instanceof ManuallyCollectedError ||
       error instanceof PendingChangeError ||
-      error instanceof InactiveError
+      error instanceof InactiveError ||
+      error instanceof HasPastDueInvoiceError
     ) {
       return res.status(422).end()
     }
@@ -395,6 +405,12 @@ async function manuallyCollectedSubscription(req, res) {
     const subscription =
       await SubscriptionLocator.promises.getUsersSubscription(userId)
 
+    await SplitTestHandler.promises.getAssignment(
+      req,
+      res,
+      'flexible-group-licensing-for-manually-billed-subscriptions'
+    )
+
     res.render('subscriptions/manually-collected-subscription', {
       groupName: subscription.teamName,
     })

+ 23 - 2
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js

@@ -17,6 +17,7 @@ const {
   ManuallyCollectedError,
   PendingChangeError,
   InactiveError,
+  HasPastDueInvoiceError,
 } = require('./Errors')
 const EmailHelper = require('../Helpers/EmailHelper')
 const { InvalidEmailError } = require('../Errors/Errors')
@@ -103,6 +104,22 @@ async function ensureSubscriptionHasNoPendingChanges(recurlySubscription) {
   }
 }
 
+async function ensureSubscriptionHasNoPastDueInvoice(subscription) {
+  const [paymentRecord] = await Modules.promises.hooks.fire(
+    'getPaymentFromRecord',
+    subscription
+  )
+
+  if (paymentRecord.account.hasPastDueInvoice) {
+    throw new HasPastDueInvoiceError(
+      'This subscription has a past due invoice',
+      {
+        subscriptionId: subscription._id.toString(),
+      }
+    )
+  }
+}
+
 async function getUsersGroupSubscriptionDetails(userId) {
   const subscription =
     await SubscriptionLocator.promises.getUsersSubscription(userId)
@@ -144,6 +161,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
   await ensureSubscriptionIsActive(subscription)
   await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
   await checkBillingInfoExistence(recurlySubscription, userId)
+  await ensureSubscriptionHasNoPastDueInvoice(subscription)
 
   const currentAddonQuantity =
     recurlySubscription.addOns.find(
@@ -259,10 +277,9 @@ async function updateSubscriptionPaymentTerms(
   recurlySubscription,
   poNumber
 ) {
-  const countryCode = await RecurlyClient.promises.getCountryCode(userId)
   const [termsAndConditions] = await Modules.promises.hooks.fire(
     'generateTermsAndConditions',
-    { countryCode, poNumber }
+    { currency: recurlySubscription.currency, poNumber }
   )
 
   const updateRequest = poNumber
@@ -464,6 +481,9 @@ module.exports = {
   ensureSubscriptionHasNoPendingChanges: callbackify(
     ensureSubscriptionHasNoPendingChanges
   ),
+  ensureSubscriptionHasNoPastDueInvoice: callbackify(
+    ensureSubscriptionHasNoPastDueInvoice
+  ),
   getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
   isUserPartOfGroup: callbackify(isUserPartOfGroup),
   getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
@@ -477,6 +497,7 @@ module.exports = {
     ensureSubscriptionIsActive,
     ensureSubscriptionCollectionMethodIsNotManual,
     ensureSubscriptionHasNoPendingChanges,
+    ensureSubscriptionHasNoPastDueInvoice,
     getTotalConfirmedUsersInGroup,
     isUserPartOfGroup,
     getUsersGroupSubscriptionDetails,

+ 2 - 1
services/web/app/src/Features/Subscription/SubscriptionRouter.mjs

@@ -19,11 +19,12 @@ const subscriptionRateLimiter = new RateLimiter('subscription', {
 })
 
 const MAX_NUMBER_OF_USERS = 20
+const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50
 
 const addSeatsValidateSchema = {
   body: Joi.object({
     adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
-    poNumber: Joi.string(),
+    poNumber: Joi.string().max(MAX_NUMBER_OF_PO_NUMBER_CHARACTERS),
   }),
 }
 

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

@@ -849,6 +849,7 @@
   "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_account_is_billed_manually_upgrading_subscription": "",
   "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "",
   "italics": "",
   "join_beta_program": "",
@@ -1237,6 +1238,9 @@
   "plus_more": "",
   "plus_x_additional_licenses_for_a_total_of_y_licenses": "",
   "po_number": "",
+  "po_number_can_include_digits_and_letters_only": "",
+  "po_number_must_not_exceed_x_characters": "",
+  "po_number_must_not_exceed_x_characters_plural": "",
   "postal_code": "",
   "premium": "",
   "premium_feature": "",
@@ -1850,6 +1854,7 @@
   "tooltip_show_filetree": "",
   "tooltip_show_panel": "",
   "tooltip_show_pdf": "",
+  "total_due_in_x_days": "",
   "total_due_today": "",
   "total_per_month": "",
   "total_per_year": "",
@@ -2034,6 +2039,7 @@
   "we_sent_new_code": "",
   "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "",
   "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "",
+  "we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months": "",
   "we_will_use_your_existing_payment_method": "",
   "webinars": "",
   "website_status": "",

+ 44 - 2
services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx

@@ -33,6 +33,7 @@ import { sendMB } from '../../../../infrastructure/event-tracking'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 export const MAX_NUMBER_OF_USERS = 20
+export const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50
 
 type CostSummaryData = MergeAndOverride<
   SubscriptionChangePreview,
@@ -47,6 +48,7 @@ function AddSeats() {
   const isProfessional = getMeta('ol-isProfessional')
   const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual')
   const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
+  const [poNumberInputError, setPoNumberInputError] = useState<string>()
   const [shouldContactSales, setShouldContactSales] = useState(false)
   const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag(
     'flexible-group-licensing-for-manually-billed-subscriptions'
@@ -125,6 +127,38 @@ function AddSeats() {
     }
   }
 
+  const poNumberValidationSchema = useMemo(() => {
+    return yup
+      .string()
+      .matches(
+        /^[\p{L}\p{N}]*$/u,
+        t('po_number_can_include_digits_and_letters_only')
+      )
+      .max(
+        MAX_NUMBER_OF_PO_NUMBER_CHARACTERS,
+        t('po_number_must_not_exceed_x_characters', {
+          count: MAX_NUMBER_OF_PO_NUMBER_CHARACTERS,
+        })
+      )
+  }, [t])
+
+  const validatePoNumber = async (value: string | undefined) => {
+    try {
+      await poNumberValidationSchema.validate(value)
+      setPoNumberInputError(undefined)
+
+      return true
+    } catch (error) {
+      if (error instanceof yup.ValidationError) {
+        setPoNumberInputError(error.errors[0])
+      } else {
+        debugConsole.error(error)
+      }
+
+      return false
+    }
+  }
+
   const handleSeatsChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
     const value = e.target.value === '' ? undefined : e.target.value
     const isValidSeatsNumber = await validateSeats(value)
@@ -161,7 +195,10 @@ function AddSeats() {
       ? undefined
       : (formData.get('po_number') as string)
 
-    if (!(await validateSeats(rawSeats))) {
+    if (
+      !(await validateSeats(rawSeats)) ||
+      !(await validatePoNumber(poNumber))
+    ) {
       return
     }
 
@@ -337,7 +374,12 @@ function AddSeats() {
                     )}
                   </FormGroup>
                   {isFlexibleGroupLicensingForManuallyBilledSubscriptions &&
-                    isCollectionMethodManual && <PoNumber />}
+                    isCollectionMethodManual && (
+                      <PoNumber
+                        error={poNumberInputError}
+                        validate={validatePoNumber}
+                      />
+                    )}
                 </div>
                 <CostSummarySection
                   isLoadingCostSummary={isLoadingCostSummary}

+ 17 - 4
services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx

@@ -7,6 +7,7 @@ import {
   SubscriptionChangePreview,
 } from '../../../../../../types/subscription/subscription-change-preview'
 import { MergeAndOverride } from '../../../../../../types/utils'
+import getMeta from '@/utils/meta'
 
 type CostSummaryProps = {
   subscriptionChange: MergeAndOverride<
@@ -18,6 +19,7 @@ type CostSummaryProps = {
 
 function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
   const { t } = useTranslation()
+  const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual')
   const factor = 100
 
   return (
@@ -109,7 +111,13 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
                   className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
                   data-testid="total"
                 >
-                  <strong className="me-auto">{t('total_due_today')}</strong>
+                  <strong className="me-auto">
+                    {isCollectionMethodManual
+                      ? t('total_due_in_x_days', {
+                          days: subscriptionChange.netTerms,
+                        })
+                      : t('total_due_today')}
+                  </strong>
                   <strong data-testid="price">
                     {formatCurrency(
                       subscriptionChange.immediateCharge.total,
@@ -121,9 +129,14 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
               <hr className="m-0" />
             </div>
             <div>
-              {t(
-                'we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months'
-              )}
+              {isCollectionMethodManual
+                ? t(
+                    'we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months',
+                    { days: subscriptionChange.netTerms }
+                  )
+                : t(
+                    'we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months'
+                  )}
             </div>
             <div>
               {t(

+ 16 - 2
services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx

@@ -1,9 +1,15 @@
 import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { FormControl, FormGroup, FormLabel } from 'react-bootstrap-5'
+import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
 import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
 
-function PoNumber() {
+type PoNumberProps = {
+  error: string | undefined
+  validate: (value: string | undefined) => Promise<boolean>
+}
+
+function PoNumber({ error, validate }: PoNumberProps) {
   const { t } = useTranslation()
   const [isPoNumberChecked, setIsPoNumberChecked] = useState(false)
 
@@ -20,7 +26,15 @@ function PoNumber() {
       {isPoNumberChecked && (
         <FormGroup className="mt-2" controlId="po-number">
           <FormLabel>{t('po_number')}</FormLabel>
-          <FormControl type="text" required className="w-25" name="po_number" />
+          <FormControl
+            type="text"
+            required
+            className="w-25"
+            name="po_number"
+            onChange={async e => await validate(e.target.value)}
+            isInvalid={Boolean(error)}
+          />
+          {Boolean(error) && <FormText type="error">{error}</FormText>}
         </FormGroup>
       )}
     </>

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

@@ -1,9 +1,20 @@
 import { Trans, useTranslation } from 'react-i18next'
 import OLNotification from '@/features/ui/components/ol/ol-notification'
 import Card from '@/features/group-management/components/card'
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 function ManuallyCollectedSubscription() {
   const { t } = useTranslation()
+  const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag(
+    'flexible-group-licensing-for-manually-billed-subscriptions'
+  )
+
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
 
   return (
     <Card>
@@ -11,13 +22,23 @@ function ManuallyCollectedSubscription() {
         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" />,
-            ]}
-          />
+          isFlexibleGroupLicensingForManuallyBilledSubscriptions ? (
+            <Trans
+              i18nKey="it_looks_like_your_account_is_billed_manually_upgrading_subscription"
+              components={[
+                // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                <a href="/contact" rel="noreferrer noopener" />,
+              ]}
+            />
+          ) : (
+            <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"
       />

+ 1 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx

@@ -354,7 +354,7 @@ function FlexibleGroupLicensingActions({
 }) {
   const { t } = useTranslation()
 
-  if (subscription.pendingPlan) {
+  if (subscription.pendingPlan || subscription.payment.hasPastDueInvoice) {
     return null
   }
 

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

@@ -1,9 +1,14 @@
 import '../base'
 import { createRoot } from 'react-dom/client'
 import ManuallyCollectedSubscription from '@/features/group-management/components/manually-collected-subscription'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
 
 const element = document.getElementById('manually-collected-subscription-root')
 if (element) {
   const root = createRoot(element)
-  root.render(<ManuallyCollectedSubscription />)
+  root.render(
+    <SplitTestProvider>
+      <ManuallyCollectedSubscription />
+    </SplitTestProvider>
+  )
 }

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

@@ -1105,6 +1105,7 @@
   "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_account_is_billed_manually_upgrading_subscription": "It looks like your account is being billed manually - 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.",
   "italics": "Italics",
   "ja": "Japanese",
@@ -1642,6 +1643,8 @@
   "plus_more": "plus more",
   "plus_x_additional_licenses_for_a_total_of_y_licenses": "Plus <0>__additionalLicenses__</0> additional license(s) for a total of <1>__count__ licenses</1>",
   "po_number": "PO Number",
+  "po_number_can_include_digits_and_letters_only": "PO number can include digits and letters only",
+  "po_number_must_not_exceed_x_characters": "PO number must not exceed __count__ characters",
   "popular_tags": "Popular Tags",
   "portal_add_affiliation_to_join": "It looks like you are already logged in to __appName__. If you have a __portalTitle__ email you can add it now.",
   "position": "Position",
@@ -2377,6 +2380,7 @@
   "tooltip_show_pdf": "Click to show the PDF",
   "top_pick": "Top pick",
   "total": "Total",
+  "total_due_in_x_days": "Total due in __days__ days",
   "total_due_today": "Total due today",
   "total_per_month": "Total per month",
   "total_per_year": "Total per year",
@@ -2581,6 +2585,7 @@
   "we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.",
   "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "We’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription.",
   "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "We’ll charge you now for your new plan based on the remaining months of your current subscription.",
+  "we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months": "We’ll invoice you now for the additional licences based on the remaining months of your current subscription, and payment will be due in __days__ days.",
   "we_will_use_your_existing_payment_method": "We’ll use your existing payment method <strong>__paymentMethod__</strong>.",
   "webinars": "Webinars",
   "website_status": "Website status",

+ 69 - 7
services/web/test/frontend/features/group-management/components/add-seats.spec.tsx

@@ -1,5 +1,6 @@
 import AddSeats, {
   MAX_NUMBER_OF_USERS,
+  MAX_NUMBER_OF_PO_NUMBER_CHARACTERS,
 } from '@/features/group-management/components/add-seats/add-seats'
 import { SplitTestProvider } from '@/shared/context/split-test-context'
 
@@ -97,6 +98,30 @@ describe('<AddSeats />', function () {
       cy.findByLabelText(/i want to add a po number/i).check()
       cy.findByLabelText(/^po number$/i)
     })
+
+    describe('validation', function () {
+      beforeEach(function () {
+        cy.findByLabelText(/i want to add a po number/i).check()
+      })
+
+      it('should show max characters error', function () {
+        const totalCharacters = 'a'.repeat(
+          MAX_NUMBER_OF_PO_NUMBER_CHARACTERS + 1
+        )
+        cy.findByLabelText(/^po number$/i).type(totalCharacters)
+        cy.findByText(
+          new RegExp(
+            `po number must not exceed ${MAX_NUMBER_OF_PO_NUMBER_CHARACTERS} characters`,
+            'i'
+          )
+        )
+      })
+
+      it('should show letters and numbers only error', function () {
+        cy.findByLabelText(/^po number$/i).type('🚧')
+        cy.findByText(/po number can include digits and letters only/i)
+      })
+    })
   })
 
   describe('"Upgrade my plan" link', function () {
@@ -251,6 +276,7 @@ describe('<AddSeats />', function () {
             },
           },
           currency: 'USD',
+          netTerms: 30,
           immediateCharge: {
             subtotal: 100,
             tax: 20,
@@ -276,13 +302,16 @@ describe('<AddSeats />', function () {
         cy.findByRole('button', { name: /send request/i }).should('not.exist')
       })
 
-      it('renders the preview data', function () {
+      function makeRequest(body: object, inputValue: string) {
         cy.intercept('POST', '/user/subscription/group/add-users/preview', {
           statusCode: 200,
-          body: this.body,
+          body,
         }).as('addUsersRequest')
-        cy.get('@input').type(this.adding.toString())
+        cy.get('@input').type(inputValue)
+      }
 
+      it('renders common preview data content', function () {
+        makeRequest(this.body, this.adding.toString())
         cy.findByTestId('cost-summary').within(() => {
           cy.contains(
             new RegExp(
@@ -314,22 +343,55 @@ describe('<AddSeats />', function () {
           cy.findByTestId('discount').should('not.exist')
 
           cy.findByTestId('total').within(() => {
-            cy.findByText(/total due today/i)
             cy.findByTestId('price').should(
               'have.text',
               `$${this.body.immediateCharge.total}.00`
             )
           })
 
-          cy.findByText(
-            /we’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
-          )
           cy.findByText(
             /after that, we’ll bill you \$1,000\.00 \(\$895\.00 \+ \$105\.00 tax\) annually on December 1, unless you cancel/i
           )
         })
       })
 
+      it('renders the preview data with manually billed subscription', function () {
+        makeRequest(this.body, this.adding.toString())
+        cy.findByTestId('cost-summary').within(() => {
+          cy.findByTestId('total').within(() => {
+            cy.findByText(
+              new RegExp(`total due in ${this.body.netTerms} days`, 'i')
+            )
+          })
+        })
+        cy.findByText(
+          new RegExp(
+            `we’ll invoice you now for the additional licences based on the remaining months of your current subscription, and payment will be due in ${this.body.netTerms} days`,
+            'i'
+          )
+        )
+      })
+
+      it('renders the preview data with automatically billed subscription', function () {
+        cy.window().then(win => {
+          win.metaAttributesCache.set('ol-isCollectionMethodManual', false)
+        })
+        cy.mount(
+          <SplitTestProvider>
+            <AddSeats />
+          </SplitTestProvider>
+        )
+        makeRequest(this.body, this.adding.toString())
+        cy.findByTestId('cost-summary').within(() => {
+          cy.findByTestId('total').within(() => {
+            cy.findByText(/total due today/i)
+          })
+        })
+        cy.findByText(
+          /we’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
+        )
+      })
+
       it('renders the preview data with discount', function () {
         this.body.immediateCharge.discount = 50
 

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

@@ -412,6 +412,7 @@ describe('PaymentProviderEntities', function () {
           periodStart: new Date(),
           periodEnd: new Date(),
           collectionMethod: 'automatic',
+          netTerms: 0,
           poNumber: '012345',
           termsAndConditions: 'T&C copy',
         })

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

@@ -58,6 +58,7 @@ describe('RecurlyClient', function () {
       periodStart: new Date(),
       periodEnd: new Date(),
       collectionMethod: 'automatic',
+      netTerms: 0,
       poNumber: '',
       termsAndConditions: '',
     })
@@ -90,6 +91,7 @@ describe('RecurlyClient', function () {
       currentPeriodStartedAt: this.subscription.periodStart,
       currentPeriodEndsAt: this.subscription.periodEnd,
       collectionMethod: this.subscription.collectionMethod,
+      netTerms: this.subscription.netTerms,
       poNumber: this.subscription.poNumber,
       termsAndConditions: this.subscription.termsAndConditions,
     }
@@ -690,29 +692,4 @@ describe('RecurlyClient', function () {
       ).to.be.rejectedWith(Error)
     })
   })
-
-  describe('getCountryCode', function () {
-    it('should return the country code from the account info', async function () {
-      this.client.getAccount = sinon.stub().resolves({
-        address: {
-          country: 'GB',
-        },
-      })
-      const countryCode = await this.RecurlyClient.promises.getCountryCode(
-        this.user._id
-      )
-      expect(countryCode).to.equal('GB')
-    })
-
-    it('should throw if country code doesn’t exist', async function () {
-      this.client.getAccount = sinon.stub().resolves({
-        address: {
-          country: '',
-        },
-      })
-      await expect(
-        this.RecurlyClient.promises.getCountryCode(this.user._id)
-      ).to.be.rejectedWith(Error, 'Country code not found')
-    })
-  })
 })

+ 19 - 0
services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs

@@ -67,6 +67,7 @@ describe('SubscriptionGroupController', function () {
         ensureSubscriptionIsActive: sinon.stub().resolves(),
         ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(),
         ensureSubscriptionHasNoPendingChanges: sinon.stub().resolves(),
+        ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(),
         getGroupPlanUpgradePreview: sinon
           .stub()
           .resolves(this.previewSubscriptionChangeData),
@@ -138,6 +139,7 @@ describe('SubscriptionGroupController', function () {
       PendingChangeError: class extends Error {},
       InactiveError: class extends Error {},
       SubtotalLimitExceededError: class extends Error {},
+      HasPastDueInvoiceError: class extends Error {},
     }
 
     this.Controller = await esmock.strict(modulePath, {
@@ -370,6 +372,9 @@ describe('SubscriptionGroupController', function () {
           this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
             .calledWith(this.subscription)
             .should.equal(true)
+          this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice
+            .calledWith(this.subscription)
+            .should.equal(true)
           this.SubscriptionGroupHandler.promises.checkBillingInfoExistence
             .calledWith(this.recurlySubscription, this.adminUserId)
             .should.equal(true)
@@ -459,6 +464,20 @@ describe('SubscriptionGroupController', function () {
 
       this.Controller.addSeatsToGroupSubscription(this.req, res)
     })
+
+    it('should redirect to subscription page when subscription has pending invoice', function (done) {
+      this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice =
+        sinon.stub().rejects()
+
+      const res = {
+        redirect: url => {
+          url.should.equal('/user/subscription')
+          done()
+        },
+      }
+
+      this.Controller.addSeatsToGroupSubscription(this.req, res)
+    })
   })
 
   describe('previewAddSeatsSubscriptionChange', function () {

+ 26 - 7
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -146,7 +146,6 @@ describe('SubscriptionGroupHandler', function () {
         applySubscriptionChangeRequest: sinon
           .stub()
           .resolves(this.applySubscriptionChange),
-        getCountryCode: sinon.stub().resolves('BG'),
         updateSubscriptionDetails: sinon.stub().resolves(),
       },
     }
@@ -198,6 +197,11 @@ describe('SubscriptionGroupHandler', function () {
             if (hookName === 'generateTermsAndConditions') {
               return Promise.resolve(['T&Cs'])
             }
+            if (hookName === 'getPaymentFromRecord') {
+              return Promise.resolve([
+                { account: { hasPastDueInvoice: false } },
+              ])
+            }
             return Promise.resolve()
           }),
         },
@@ -504,9 +508,6 @@ describe('SubscriptionGroupHandler', function () {
     describe('updateSubscriptionPaymentTerms', function () {
       describe('accounts with PO number', function () {
         it('should update the subscription PO number and T&C', async function () {
-          this.RecurlyClient.promises.getCountryCode = sinon
-            .stub()
-            .resolves('GB')
           await this.Handler.promises.updateSubscriptionPaymentTerms(
             this.adminUser_id,
             this.recurlySubscription,
@@ -526,9 +527,6 @@ describe('SubscriptionGroupHandler', function () {
 
       describe('accounts with no PO number', function () {
         it('should update the subscription T&C only', async function () {
-          this.RecurlyClient.promises.getCountryCode = sinon
-            .stub()
-            .resolves('GB')
           await this.Handler.promises.updateSubscriptionPaymentTerms(
             this.adminUser_id,
             this.recurlySubscription
@@ -758,6 +756,27 @@ describe('SubscriptionGroupHandler', function () {
     })
   })
 
+  describe('ensureSubscriptionHasNoPastDueInvoice', function () {
+    it('should throw if the subscription has past due invoice', async function () {
+      this.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord')
+        .resolves([{ account: { hasPastDueInvoice: true } }])
+      await expect(
+        this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
+          this.subscription
+        )
+      ).to.be.rejectedWith('This subscription has a past due invoice')
+    })
+
+    it('should not throw if the subscription has no past due invoice', async function () {
+      await expect(
+        this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
+          this.subscription
+        )
+      ).to.not.be.rejected
+    })
+  })
+
   describe('upgradeGroupPlan', function () {
     it('should upgrade the subscription for flexible licensing group plans', async function () {
       this.SubscriptionLocator.promises.getUsersSubscription = sinon

+ 1 - 0
services/web/types/subscription/subscription-change-preview.ts

@@ -2,6 +2,7 @@ export type SubscriptionChangePreview = {
   change: SubscriptionChangeDescription
   currency: string
   paymentMethod: string | undefined
+  netTerms: number
   nextPlan: {
     annual: boolean
   }