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

Merge pull request #11819 from overleaf/jel-subscription-dash-change-to-group-prices

[web] Show price in change to group plan modal on React subscription dash

GitOrigin-RevId: 6a1a4be3a7d008cd9e26186c2d97bc0bdc2f82ed
Jessica Lawshe 3 лет назад
Родитель
Сommit
124306d7ac

+ 37 - 1
package-lock.json

@@ -7441,6 +7441,16 @@
         "url-parse": "^1.4.7"
       }
     },
+    "node_modules/@popperjs/core": {
+      "version": "2.11.6",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
+      "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
+      "dev": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
     "node_modules/@protobufjs/aspromise": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -8108,6 +8118,15 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/bootstrap": {
+      "version": "5.2.6",
+      "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.6.tgz",
+      "integrity": "sha512-BlAc3YATdasbHoxMoBWODrSF6qwQO/E9X8wVxCCSa6rWjnaZfpkr2N6pUMCY6jj2+wf0muUtLySbvU9etX6YqA==",
+      "dev": true,
+      "dependencies": {
+        "@popperjs/core": "^2.9.2"
+      }
+    },
     "node_modules/@types/bson": {
       "version": "4.0.5",
       "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.5.tgz",
@@ -34569,6 +34588,7 @@
         "@testing-library/react": "^12.1.5",
         "@testing-library/react-hooks": "^8.0.0",
         "@testing-library/user-event": "^14.2.0",
+        "@types/bootstrap": "^5.2.6",
         "@types/chai": "^4.3.0",
         "@types/events": "^3.0.0",
         "@types/express": "^4.17.13",
@@ -43579,6 +43599,7 @@
         "@testing-library/react": "^12.1.5",
         "@testing-library/react-hooks": "^8.0.0",
         "@testing-library/user-event": "^14.2.0",
+        "@types/bootstrap": "*",
         "@types/chai": "^4.3.0",
         "@types/events": "^3.0.0",
         "@types/express": "^4.17.13",
@@ -43732,7 +43753,7 @@
         "passport-local": "^1.0.0",
         "passport-oauth2": "^1.5.0",
         "passport-orcid": "0.0.4",
-        "passport-saml": "3.2.3",
+        "passport-saml": "^3.2.3",
         "passport-twitter": "^1.0.4",
         "pdfjs-dist213": "npm:pdfjs-dist@2.13.216",
         "pdfjs-dist31": "npm:pdfjs-dist@3.1.81",
@@ -45576,6 +45597,12 @@
         "url-parse": "^1.4.7"
       }
     },
+    "@popperjs/core": {
+      "version": "2.11.6",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
+      "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
+      "dev": true
+    },
     "@protobufjs/aspromise": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -46120,6 +46147,15 @@
         "@types/node": "*"
       }
     },
+    "@types/bootstrap": {
+      "version": "5.2.6",
+      "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.6.tgz",
+      "integrity": "sha512-BlAc3YATdasbHoxMoBWODrSF6qwQO/E9X8wVxCCSa6rWjnaZfpkr2N6pUMCY6jj2+wf0muUtLySbvU9etX6YqA==",
+      "dev": true,
+      "requires": {
+        "@popperjs/core": "^2.9.2"
+      }
+    },
     "@types/bson": {
       "version": "4.0.5",
       "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.5.tgz",

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

@@ -432,6 +432,7 @@
   "linked_file": "",
   "loading": "",
   "loading_github_repositories": "",
+  "loading_prices": "",
   "loading_recent_github_commits": "",
   "log_entry_description": "",
   "log_entry_maximum_entries": "",
@@ -820,6 +821,7 @@
   "toolbar_undo": "",
   "total_per_month": "",
   "total_per_year": "",
+  "total_with_subtotal_and_tax": "",
   "total_words": "",
   "track_changes": "",
   "trash": "",

+ 18 - 0
services/web/frontend/js/features/subscription/components/dashboard/generic-error-alert.tsx

@@ -0,0 +1,18 @@
+import classNames from 'classnames'
+import { useTranslation } from 'react-i18next'
+
+export default function GenericErrorAlert({
+  className,
+}: {
+  className?: string
+}) {
+  const { t } = useTranslation()
+  const alertClassName = classNames('alert', 'alert-danger', className)
+
+  return (
+    <div className={alertClassName} aria-live="polite">
+      {t('generic_something_went_wrong')}. {t('try_again')}.{' '}
+      {t('generic_if_problem_continues_contact_us')}.
+    </div>
+  )
+}

+ 87 - 10
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx

@@ -3,9 +3,11 @@ import { Modal } from 'react-bootstrap'
 import { useTranslation, Trans } from 'react-i18next'
 import { GroupPlans } from '../../../../../../../../../../types/subscription/dashboard/group-plans'
 import { Subscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
+import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
 import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
 import getMeta from '../../../../../../../../utils/meta'
 import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
+import GenericErrorAlert from '../../../../generic-error-alert'
 
 const educationalPercentDiscount = 40
 const groupSizeForEducationalDiscount = 10
@@ -53,21 +55,66 @@ function EducationDiscountAppliedOrNot({ groupSize }: { groupSize: string }) {
   )
 }
 
-function GroupPrice() {
+function GroupPrice({
+  groupPlanToChangeToPrice,
+  queryingGroupPlanToChangeToPrice,
+}: {
+  groupPlanToChangeToPrice?: PriceForDisplayData
+  queryingGroupPlanToChangeToPrice: boolean
+}) {
   const { t } = useTranslation()
+
+  const totalPrice =
+    !queryingGroupPlanToChangeToPrice &&
+    groupPlanToChangeToPrice?.totalForDisplay
+      ? groupPlanToChangeToPrice.totalForDisplay
+      : '…'
+
+  const perUserPrice =
+    !queryingGroupPlanToChangeToPrice &&
+    groupPlanToChangeToPrice?.perUserDisplayPrice
+      ? groupPlanToChangeToPrice.perUserDisplayPrice
+      : '…'
+
   return (
     <>
       <span aria-hidden>
-        X <span className="small">/ {t('year')}</span>
+        {totalPrice} <span className="small">/ {t('year')}</span>
       </span>
       <span className="sr-only">
-        {/* TODO: price */}
-        <Trans i18nKey="x_price_per_year" values={{ price: '$X' }} />
+        {queryingGroupPlanToChangeToPrice ? (
+          t('loading_prices')
+        ) : (
+          <Trans
+            i18nKey="x_price_per_year"
+            values={{ price: groupPlanToChangeToPrice?.totalForDisplay }}
+          />
+        )}
       </span>
+
       <br />
+
       <span className="circle-subtext">
-        {/* TODO: price */}
-        <Trans i18nKey="x_price_per_user" values={{ price: '$X' }} />
+        <span aria-hidden>
+          <Trans
+            i18nKey="x_price_per_user"
+            values={{
+              price: perUserPrice,
+            }}
+          />
+        </span>
+        <span className="sr-only">
+          {queryingGroupPlanToChangeToPrice ? (
+            t('loading_prices')
+          ) : (
+            <Trans
+              i18nKey="x_price_per_user"
+              values={{
+                price: perUserPrice,
+              }}
+            />
+          )}
+        </span>
       </span>
     </>
   )
@@ -78,10 +125,13 @@ export function ChangeToGroupModal() {
   const { t } = useTranslation()
   const {
     groupPlanToChangeToCode,
+    groupPlanToChangeToPrice,
+    groupPlanToChangeToPriceError,
     groupPlanToChangeToSize,
     groupPlanToChangeToUsage,
     handleCloseModal,
     modalIdShown,
+    queryingGroupPlanToChangeToPrice,
     setGroupPlanToChangeToCode,
     setGroupPlanToChangeToSize,
     setGroupPlanToChangeToUsage,
@@ -100,8 +150,6 @@ export function ChangeToGroupModal() {
 
   function handleGetInTouchButton() {
     handleCloseModal()
-
-    // @ts-ignore
     $('[data-ol-contact-form-modal="contact-us"]').modal()
   }
 
@@ -142,10 +190,16 @@ export function ChangeToGroupModal() {
 
       <Modal.Body>
         <div className="container-fluid plans group-subscription-modal">
+          {groupPlanToChangeToPriceError && <GenericErrorAlert />}
           <div className="row">
             <div className="col-md-6 text-center">
               <div className="circle circle-lg">
-                <GroupPrice />
+                <GroupPrice
+                  groupPlanToChangeToPrice={groupPlanToChangeToPrice}
+                  queryingGroupPlanToChangeToPrice={
+                    queryingGroupPlanToChangeToPrice
+                  }
+                />
               </div>
               <p>{t('each_user_will_have_access_to')}:</p>
               <ul className="list-unstyled">
@@ -257,11 +311,34 @@ export function ChangeToGroupModal() {
 
       <Modal.Footer>
         <div className="text-center">
+          {groupPlanToChangeToPrice?.includesTax && (
+            <p>
+              <Trans
+                i18nKey="total_with_subtotal_and_tax"
+                values={{
+                  total: groupPlanToChangeToPrice.totalForDisplay,
+                  subtotal: groupPlanToChangeToPrice.subtotal,
+                  tax: groupPlanToChangeToPrice.tax,
+                }}
+                components={[
+                  /* eslint-disable-next-line react/jsx-key */
+                  <strong />,
+                ]}
+              />
+            </p>
+          )}
           <p>
             <strong>{t('new_subscription_will_be_billed_immediately')}</strong>
           </p>
           <hr className="thin" />
-          <button className="btn btn-primary btn-lg">{t('upgrade_now')}</button>
+          <button
+            className="btn btn-primary btn-lg"
+            disabled={
+              queryingGroupPlanToChangeToPrice || !groupPlanToChangeToPrice
+            }
+          >
+            {t('upgrade_now')}
+          </button>
           <hr className="thin" />
           <button className="btn-inline-link" onClick={handleGetInTouchButton}>
             <Trans i18nKey="need_more_than_x_licenses" values={{ x: 50 }} />{' '}

+ 64 - 2
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -11,11 +11,17 @@ import {
   ManagedGroupSubscription,
   Subscription,
 } from '../../../../../types/subscription/dashboard/subscription'
-import { Plan } from '../../../../../types/subscription/plan'
+import {
+  Plan,
+  PriceForDisplayData,
+} from '../../../../../types/subscription/plan'
 import { Institution as ManagedInstitution } from '../components/dashboard/managed-institutions'
 import { Institution } from '../../../../../types/institution'
 import getMeta from '../../../utils/meta'
-import { loadDisplayPriceWithTaxPromise } from '../util/recurly-pricing'
+import {
+  loadDisplayPriceWithTaxPromise,
+  loadGroupDisplayPriceWithTaxPromise,
+} from '../util/recurly-pricing'
 import { isRecurlyLoaded } from '../util/is-recurly-loaded'
 import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids'
 
@@ -23,6 +29,8 @@ type SubscriptionDashboardContextValue = {
   groupPlanToChangeToCode?: string
   groupPlanToChangeToSize: string
   groupPlanToChangeToUsage?: string
+  groupPlanToChangeToPrice?: PriceForDisplayData
+  groupPlanToChangeToPriceError?: boolean
   handleCloseModal: () => void
   handleOpenModal: (
     modalIdToOpen: SubscriptionDashModalIds,
@@ -37,6 +45,7 @@ type SubscriptionDashboardContextValue = {
   personalSubscription?: Subscription
   plans: Plan[]
   planCodeToChangeTo?: string
+  queryingGroupPlanToChangeToPrice: boolean
   queryingIndividualPlansData: boolean
   recurlyLoadError: boolean
   setGroupPlanToChangeToCode: React.Dispatch<
@@ -84,6 +93,14 @@ export function SubscriptionDashboardProvider({
   >()
   const [groupPlanToChangeToUsage, setGroupPlanToChangeToUsage] =
     useState('enterprise')
+  const [
+    queryingGroupPlanToChangeToPrice,
+    setQueryingGroupPlanToChangeToPrice,
+  ] = useState(false)
+  const [groupPlanToChangeToPrice, setGroupPlanToChangeToPrice] =
+    useState<PriceForDisplayData>()
+  const [groupPlanToChangeToPriceError, setGroupPlanToChangeToPriceError] =
+    useState(false)
 
   const plansWithoutDisplayPrice = getMeta('ol-plans')
   const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
@@ -133,6 +150,44 @@ export function SubscriptionDashboardProvider({
     }
   }, [personalSubscription, plansWithoutDisplayPrice])
 
+  useEffect(() => {
+    if (
+      isRecurlyLoaded() &&
+      groupPlanToChangeToCode &&
+      groupPlanToChangeToSize &&
+      groupPlanToChangeToUsage &&
+      personalSubscription
+    ) {
+      setQueryingGroupPlanToChangeToPrice(true)
+
+      const { currency, taxRate } = personalSubscription.recurly
+      const fetchGroupDisplayPrice = async () => {
+        setGroupPlanToChangeToPriceError(false)
+        let priceData
+        try {
+          priceData = await loadGroupDisplayPriceWithTaxPromise(
+            groupPlanToChangeToCode,
+            currency,
+            taxRate,
+            groupPlanToChangeToSize,
+            groupPlanToChangeToUsage
+          )
+        } catch (e) {
+          console.error(e)
+          setGroupPlanToChangeToPriceError(true)
+        }
+        setQueryingGroupPlanToChangeToPrice(false)
+        setGroupPlanToChangeToPrice(priceData)
+      }
+      fetchGroupDisplayPrice()
+    }
+  }, [
+    groupPlanToChangeToUsage,
+    groupPlanToChangeToSize,
+    personalSubscription,
+    groupPlanToChangeToCode,
+  ])
+
   const updateManagedInstitution = useCallback(
     (institution: ManagedInstitution) => {
       setManagedInstitutions(institutions => {
@@ -145,6 +200,7 @@ export function SubscriptionDashboardProvider({
     },
     []
   )
+
   const handleCloseModal = useCallback(() => {
     setModalIdShown(undefined)
     setPlanCodeToChangeTo(undefined)
@@ -161,6 +217,8 @@ export function SubscriptionDashboardProvider({
   const value = useMemo<SubscriptionDashboardContextValue>(
     () => ({
       groupPlanToChangeToCode,
+      groupPlanToChangeToPrice,
+      groupPlanToChangeToPriceError,
       groupPlanToChangeToSize,
       groupPlanToChangeToUsage,
       handleCloseModal,
@@ -174,6 +232,7 @@ export function SubscriptionDashboardProvider({
       personalSubscription,
       plans,
       planCodeToChangeTo,
+      queryingGroupPlanToChangeToPrice,
       queryingIndividualPlansData,
       recurlyLoadError,
       setGroupPlanToChangeToCode,
@@ -189,6 +248,8 @@ export function SubscriptionDashboardProvider({
     }),
     [
       groupPlanToChangeToCode,
+      groupPlanToChangeToPrice,
+      groupPlanToChangeToPriceError,
       groupPlanToChangeToSize,
       groupPlanToChangeToUsage,
       handleCloseModal,
@@ -202,6 +263,7 @@ export function SubscriptionDashboardProvider({
       personalSubscription,
       plans,
       planCodeToChangeTo,
+      queryingGroupPlanToChangeToPrice,
       queryingIndividualPlansData,
       recurlyLoadError,
       setGroupPlanToChangeToCode,

+ 0 - 0
services/web/frontend/js/features/subscription/util/is-recurly-loaded.tsx → services/web/frontend/js/features/subscription/util/is-recurly-loaded.ts


+ 40 - 3
services/web/frontend/js/features/subscription/util/recurly-pricing.tsx → services/web/frontend/js/features/subscription/util/recurly-pricing.ts

@@ -1,4 +1,5 @@
 import { SubscriptionPricingState } from '@recurly/recurly-js'
+import { PriceForDisplayData } from '../../../../../types/subscription/plan'
 import { currencies, CurrencyCode } from '../data/currency'
 
 function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) {
@@ -19,7 +20,7 @@ function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) {
   })
 }
 
-function priceWithCents(price: number): string | number {
+function priceToWithCents(price: number) {
   return price % 1 !== 0 ? price.toFixed(2) : price
 }
 
@@ -27,7 +28,7 @@ export function formatPriceForDisplayData(
   price: string,
   taxRate: number,
   currencyCode: CurrencyCode
-) {
+): PriceForDisplayData {
   const currencySymbol = currencies[currencyCode]
 
   const totalPriceExTax = parseFloat(price)
@@ -38,7 +39,7 @@ export function formatPriceForDisplayData(
   const totalWithTax = totalPriceExTax + taxAmount
 
   return {
-    totalForDisplay: `${currencySymbol}${priceWithCents(totalWithTax)}`,
+    totalForDisplay: `${currencySymbol}${priceToWithCents(totalWithTax)}`,
     totalAsNumber: totalWithTax,
     subtotal: `${currencySymbol}${totalPriceExTax.toFixed(2)}`,
     tax: `${currencySymbol}${taxAmount.toFixed(2)}`,
@@ -46,6 +47,14 @@ export function formatPriceForDisplayData(
   }
 }
 
+function getPerUserDisplayPrice(
+  totalPrice: number,
+  currencySymbol: string,
+  size: string
+): string {
+  return `${currencySymbol}${priceToWithCents(totalPrice / parseInt(size))}`
+}
+
 export async function loadDisplayPriceWithTaxPromise(
   planCode: string,
   currencyCode: CurrencyCode,
@@ -60,3 +69,31 @@ export async function loadDisplayPriceWithTaxPromise(
   if (price)
     return formatPriceForDisplayData(price.next.total, taxRate, currencyCode)
 }
+
+export async function loadGroupDisplayPriceWithTaxPromise(
+  groupPlanCode: string,
+  currencyCode: CurrencyCode,
+  taxRate: number,
+  size: string,
+  usage: string
+) {
+  if (!recurly) return
+
+  const planCode = `group_${groupPlanCode}_${size}_${usage}`
+  const price = await loadDisplayPriceWithTaxPromise(
+    planCode,
+    currencyCode,
+    taxRate
+  )
+
+  if (price) {
+    const currencySymbol = currencies[currencyCode]
+    price.perUserDisplayPrice = getPerUserDisplayPrice(
+      price.totalAsNumber,
+      currencySymbol,
+      size
+    )
+  }
+
+  return price
+}

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

@@ -839,6 +839,7 @@
   "loading": "Loading",
   "loading_content": "Creating Project",
   "loading_github_repositories": "Loading your GitHub repositories",
+  "loading_prices": "loading prices",
   "loading_recent_github_commits": "Loading recent commits",
   "log_entry_description": "Log entry with level: __level__",
   "log_entry_maximum_entries": "Maximum log entries limit hit",
@@ -1540,6 +1541,7 @@
   "total_per_month": "Total per month",
   "total_per_year": "Total per year",
   "total_per_year_for_x_users": "total per year for __licenseSize__ users",
+  "total_with_subtotal_and_tax": "Total: <0>__total__</0> (__subtotal__ + __tax__ tax) per year",
   "total_words": "Total Words",
   "tr": "Turkish",
   "track_any_change_in_real_time": "Track any change, in real-time",

+ 1 - 0
services/web/package.json

@@ -258,6 +258,7 @@
     "@testing-library/react": "^12.1.5",
     "@testing-library/react-hooks": "^8.0.0",
     "@testing-library/user-event": "^14.2.0",
+    "@types/bootstrap": "^5.2.6",
     "@types/chai": "^4.3.0",
     "@types/events": "^3.0.0",
     "@types/express": "^4.17.13",

+ 126 - 27
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

@@ -1,9 +1,12 @@
 import { expect } from 'chai'
 import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
 import { ChangePlan } from '../../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-plan'
 import { groupPlans, plans } from '../../../../../fixtures/plans'
 import {
   annualActiveSubscription,
+  annualActiveSubscriptionEuro,
+  annualActiveSubscriptionPro,
   pendingSubscriptionChange,
 } from '../../../../../fixtures/subscriptions'
 import { ActiveSubscription } from '../../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
@@ -326,9 +329,11 @@ describe('<ChangePlan />', function () {
   describe('Change to group plan modal', function () {
     const standardPlanCollaboratorText = '10 collaborators per project'
     const professionalPlanCollaboratorText = 'Unlimited collaborators'
-    it('open group plan modal "Change to a group plan" clicked', async function () {
-      renderActiveSubscription(annualActiveSubscription)
+    const educationInputLabel =
+      'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
 
+    let modal: HTMLElement
+    async function openModal() {
       const button = screen.getByRole('button', { name: 'Change plan' })
       fireEvent.click(button)
 
@@ -337,10 +342,19 @@ describe('<ChangePlan />', function () {
       })
       fireEvent.click(buttonGroupModal)
 
-      const modal = await screen.findByRole('dialog')
+      modal = await screen.findByRole('dialog')
+    }
+
+    it('open group plan modal "Change to a group plan" clicked', async function () {
+      renderActiveSubscription(annualActiveSubscription)
+      await openModal()
 
       within(modal).getByText('Customize your group subscription')
       within(modal).getByText('Save 30% or more')
+
+      within(modal).getByText('$1290 per year')
+      expect(within(modal).getAllByText('$129 per user').length).to.equal(2)
+
       within(modal).getByText('Each user will have access to:')
       within(modal).getByText('All premium features')
       within(modal).getByText('Sync with Dropbox and GitHub')
@@ -354,15 +368,23 @@ describe('<ChangePlan />', function () {
       const plans = within(modal).getByRole('group')
       const planOptions = within(plans).getAllByRole('radio')
       expect(planOptions.length).to.equal(groupPlans.plans.length)
+      const standardPlanRadioInput = within(modal).getByLabelText(
+        'Standard'
+      ) as HTMLInputElement
+      expect(standardPlanRadioInput.checked).to.be.true
 
-      const sizeSelect = within(modal).getByRole('combobox')
+      const sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement
+      expect(sizeSelect.value).to.equal('10')
       const sizeOption = within(sizeSelect).getAllByRole('option')
       expect(sizeOption.length).to.equal(groupPlans.sizes.length)
       within(modal).getByText(
         'Overleaf offers a 40% educational discount for groups of 10 or more.'
       )
 
-      within(modal).getByRole('checkbox')
+      const educationalCheckbox = within(modal).getByRole(
+        'checkbox'
+      ) as HTMLInputElement
+      expect(educationalCheckbox.checked).to.be.false
       within(modal).getByText(
         'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
       )
@@ -371,6 +393,8 @@ describe('<ChangePlan />', function () {
         'Your new subscription will be billed immediately to your current payment method.'
       )
 
+      expect(within(modal).queryByText('tax', { exact: false })).to.be.null
+
       within(modal).getByRole('button', { name: 'Upgrade Now' })
 
       within(modal).getByRole('button', {
@@ -380,46 +404,121 @@ describe('<ChangePlan />', function () {
 
     it('changes the collaborator count when the plan changes', async function () {
       renderActiveSubscription(annualActiveSubscription)
+      await openModal()
 
-      const button = screen.getByRole('button', { name: 'Change plan' })
-      fireEvent.click(button)
-
-      const buttonGroupModal = await screen.findByRole('button', {
-        name: 'Change to a group plan',
-      })
-      fireEvent.click(buttonGroupModal)
-
-      const modal = await screen.findByRole('dialog')
       const professionalPlanOption =
         within(modal).getByLabelText('Professional')
       fireEvent.click(professionalPlanOption)
 
-      within(modal).getByText(professionalPlanCollaboratorText)
+      await within(modal).findByText(professionalPlanCollaboratorText)
       expect(within(modal).queryByText(standardPlanCollaboratorText)).to.be.null
     })
 
-    it('shows educational discount applied when input checked', async function () {
+    it('shows educational discount applied when input checked and also notes if not enough users to get discount', async function () {
       const discountAppliedText = '40% educational discount applied!'
       const discountNotAppliedText =
         'The educational discount is available for groups of 10 or more'
       renderActiveSubscription(annualActiveSubscription)
 
-      const button = screen.getByRole('button', { name: 'Change plan' })
-      fireEvent.click(button)
+      await openModal()
 
-      const buttonGroupModal = await screen.findByRole('button', {
-        name: 'Change to a group plan',
+      const educationInput = within(modal).getByLabelText(educationInputLabel)
+      fireEvent.click(educationInput)
+      await within(modal).findByText(discountAppliedText)
+      expect(within(modal).queryByText(discountNotAppliedText)).to.be.null
+
+      const sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement
+      await userEvent.selectOptions(sizeSelect, [screen.getByText('5')])
+      await within(modal).findByText(discountNotAppliedText)
+      expect(within(modal).queryByText(discountAppliedText)).to.be.null
+    })
+
+    it('shows total with tax when tax applied', async function () {
+      renderActiveSubscription(annualActiveSubscriptionEuro, undefined, 'EUR')
+
+      await openModal()
+
+      within(modal).getByText('Total:', { exact: false })
+      expect(
+        within(modal).getAllByText('€1438.40', { exact: false }).length
+      ).to.equal(3)
+      within(modal).getByText('(€1160.00 + €278.40 tax) per year', {
+        exact: false,
       })
-      fireEvent.click(buttonGroupModal)
+    })
 
-      const modal = await screen.findByRole('dialog')
+    it('changes the price when options change', async function () {
+      renderActiveSubscription(annualActiveSubscription)
 
-      const educationInput = within(modal).getByLabelText(
-        'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
-      )
+      await openModal()
+
+      within(modal).getByText('$1290 per year')
+      within(modal).getAllByText('$129 per user')
+
+      // plan type (pro collab)
+      let standardPlanRadioInput = within(modal).getByLabelText(
+        'Standard'
+      ) as HTMLInputElement
+      expect(standardPlanRadioInput.checked).to.be.true
+      let professionalPlanRadioInput = within(modal).getByLabelText(
+        'Professional'
+      ) as HTMLInputElement
+      expect(professionalPlanRadioInput.checked).to.be.false
+
+      fireEvent.click(professionalPlanRadioInput)
+
+      standardPlanRadioInput = within(modal).getByLabelText(
+        'Standard'
+      ) as HTMLInputElement
+      expect(standardPlanRadioInput.checked).to.be.false
+      professionalPlanRadioInput = within(modal).getByLabelText(
+        'Professional'
+      ) as HTMLInputElement
+      expect(professionalPlanRadioInput.checked).to.be.true
+
+      await within(modal).findByText('$2590 per year')
+      await within(modal).findAllByText('$259 per user')
+
+      // user count
+      let sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement
+      expect(sizeSelect.value).to.equal('10')
+      await userEvent.selectOptions(sizeSelect, [screen.getByText('5')])
+      sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement
+      expect(sizeSelect.value).to.equal('5')
+
+      await within(modal).findByText('$1395 per year')
+      await within(modal).findAllByText('$279 per user')
+
+      // usage (enterprise or educational)
+      let educationInput = within(modal).getByLabelText(
+        educationInputLabel
+      ) as HTMLInputElement
+      expect(educationInput.checked).to.be.false
       fireEvent.click(educationInput)
-      within(modal).getByText(discountAppliedText)
-      expect(within(modal).queryByText(discountNotAppliedText)).to.be.null
+      educationInput = within(modal).getByLabelText(
+        educationInputLabel
+      ) as HTMLInputElement
+      expect(educationInput.checked).to.be.true
+
+      // make sure doesn't change price until back at min user to qualify
+      await within(modal).findByText('$1395 per year')
+      await within(modal).findAllByText('$279 per user')
+
+      await userEvent.selectOptions(sizeSelect, [screen.getByText('10')])
+
+      await within(modal).findByText('$1550 per year')
+      await within(modal).findAllByText('$155 per user')
+    })
+
+    it('has pro as the default group plan type if user is on a pro plan', async function () {
+      renderActiveSubscription(annualActiveSubscriptionPro)
+
+      await openModal()
+
+      const standardPlanRadioInput = within(modal).getByLabelText(
+        'Professional'
+      ) as HTMLInputElement
+      expect(standardPlanRadioInput.checked).to.be.true
     })
   })
 })

+ 199 - 0
services/web/test/frontend/features/subscription/fixtures/plans.tsx

@@ -228,3 +228,202 @@ export const groupPlans: GroupPlans = {
   ],
   sizes: ['2', '3', '4', '5', '10', '20', '50'],
 }
+
+export const groupPriceByUsageTypeAndSize = {
+  educational: {
+    professional: {
+      EUR: {
+        '2': {
+          price_in_cents: 51600,
+        },
+        '3': {
+          price_in_cents: 77400,
+        },
+        '4': {
+          price_in_cents: 103200,
+        },
+        '5': {
+          price_in_cents: 129000,
+        },
+        '10': {
+          price_in_cents: 143000,
+        },
+        '20': {
+          price_in_cents: 264000,
+        },
+        '50': {
+          price_in_cents: 605000,
+        },
+      },
+      USD: {
+        '2': {
+          price_in_cents: 55800,
+        },
+        '3': {
+          price_in_cents: 83700,
+        },
+        '4': {
+          price_in_cents: 111600,
+        },
+        '5': {
+          price_in_cents: 139500,
+        },
+        '10': {
+          price_in_cents: 155000,
+        },
+        '20': {
+          price_in_cents: 286000,
+        },
+        '50': {
+          price_in_cents: 655000,
+        },
+      },
+    },
+    collaborator: {
+      EUR: {
+        '2': {
+          price_in_cents: 25000,
+        },
+        '3': {
+          price_in_cents: 37500,
+        },
+        '4': {
+          price_in_cents: 50000,
+        },
+        '5': {
+          price_in_cents: 62500,
+        },
+        '10': {
+          price_in_cents: 69000,
+        },
+        '20': {
+          price_in_cents: 128000,
+        },
+        '50': {
+          price_in_cents: 295000,
+        },
+      },
+      USD: {
+        '2': {
+          price_in_cents: 27800,
+        },
+        '3': {
+          price_in_cents: 41700,
+        },
+        '4': {
+          price_in_cents: 55600,
+        },
+        '5': {
+          price_in_cents: 69500,
+        },
+        '10': {
+          price_in_cents: 77000,
+        },
+        '20': {
+          price_in_cents: 142000,
+        },
+        '50': {
+          price_in_cents: 325000,
+        },
+      },
+    },
+  },
+  enterprise: {
+    professional: {
+      EUR: {
+        '2': {
+          price_in_cents: 51600,
+        },
+        '3': {
+          price_in_cents: 77400,
+        },
+        '4': {
+          price_in_cents: 103200,
+        },
+        '5': {
+          price_in_cents: 129000,
+        },
+        '10': {
+          price_in_cents: 239000,
+        },
+        '20': {
+          price_in_cents: 442000,
+        },
+        '50': {
+          price_in_cents: 1010000,
+        },
+      },
+      USD: {
+        '2': {
+          price_in_cents: 55800,
+        },
+        '3': {
+          price_in_cents: 83700,
+        },
+        '4': {
+          price_in_cents: 111600,
+        },
+        '5': {
+          price_in_cents: 139500,
+        },
+        '10': {
+          price_in_cents: 259000,
+        },
+        '20': {
+          price_in_cents: 478000,
+        },
+        '50': {
+          price_in_cents: 1095000,
+        },
+      },
+    },
+    collaborator: {
+      EUR: {
+        '2': {
+          price_in_cents: 25000,
+        },
+        '3': {
+          price_in_cents: 37500,
+        },
+        '4': {
+          price_in_cents: 50000,
+        },
+        '5': {
+          price_in_cents: 62500,
+        },
+        '10': {
+          price_in_cents: 116000,
+        },
+        '20': {
+          price_in_cents: 214000,
+        },
+        '50': {
+          price_in_cents: 490000,
+        },
+      },
+      USD: {
+        '2': {
+          price_in_cents: 27800,
+        },
+        '3': {
+          price_in_cents: 41700,
+        },
+        '4': {
+          price_in_cents: 55600,
+        },
+        '5': {
+          price_in_cents: 69500,
+        },
+        '10': {
+          price_in_cents: 129000,
+        },
+        '20': {
+          price_in_cents: 238000,
+        },
+        '50': {
+          price_in_cents: 545000,
+        },
+      },
+    },
+  },
+}

+ 77 - 0
services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx

@@ -51,6 +51,83 @@ export const annualActiveSubscription: Subscription = {
   },
 }
 
+export const annualActiveSubscriptionEuro: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'collaborator-annual',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'collaborator-annual',
+    name: 'Standard (Collaborator) Annual',
+    price_in_cents: 21900,
+    annual: true,
+    featureDescription: [],
+  },
+  recurly: {
+    tax: 4296,
+    taxRate: 0.24,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    nextPaymentDueAt,
+    currency: 'EUR',
+    state: 'active',
+    trialEndsAtFormatted: null,
+    trial_ends_at: null,
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
+      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
+    },
+    displayPrice: '€221.96',
+  },
+}
+
+export const annualActiveSubscriptionPro: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'professional',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'professional',
+    name: 'Professional',
+    price_in_cents: 4500,
+    featureDescription: [],
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    nextPaymentDueAt,
+    currency: 'USD',
+    state: 'active',
+    trialEndsAtFormatted: null,
+    trial_ends_at: null,
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
+      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
+    },
+    displayPrice: '$42.00',
+  },
+}
+
 export const pastDueExpiredSubscription: Subscription = {
   manager_ids: ['abc123'],
   member_ids: [],

+ 4 - 2
services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx

@@ -5,9 +5,11 @@ import { renderWithSubscriptionDashContext } from './render-with-subscription-da
 
 export function renderActiveSubscription(
   subscription: Subscription,
-  tags: { name: string; value: string | object | Array<object> }[] = []
+  tags: { name: string; value: string | object | Array<object> }[] = [],
+  currencyCode?: string
 ) {
   const renderOptions = {
+    currencyCode,
     metaTags: [
       ...tags,
       { name: 'ol-plans', value: plans },
@@ -18,7 +20,7 @@ export function renderActiveSubscription(
       { name: 'ol-subscription', value: subscription },
       {
         name: 'ol-recommendedCurrency',
-        value: 'USD',
+        value: currencyCode || 'USD',
       },
     ],
   }

+ 17 - 2
services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx

@@ -1,6 +1,7 @@
 import { render } from '@testing-library/react'
+import _ from 'lodash'
 import { SubscriptionDashboardProvider } from '../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
-import { plans } from '../fixtures/plans'
+import { groupPriceByUsageTypeAndSize, plans } from '../fixtures/plans'
 
 export function renderWithSubscriptionDashContext(
   component: React.ReactElement,
@@ -8,6 +9,7 @@ export function renderWithSubscriptionDashContext(
     metaTags?: { name: string; value: string | object | Array<object> }[]
     recurlyNotLoaded?: boolean
     queryingRecurly?: boolean
+    currencyCode?: string
   }
 ) {
   const SubscriptionDashboardProviderWrapper = ({
@@ -31,7 +33,20 @@ export function renderWithSubscriptionDashContext(
         Subscription: () => {
           return {
             plan: (planCode: string) => {
-              const plan = plans.find(p => p.planCode === planCode)
+              let plan
+              const isGroupPlan = planCode.includes('group')
+              if (isGroupPlan) {
+                const [, planType, size, usage] = planCode.split('_')
+                const currencyCode = options?.currencyCode || 'USD'
+                plan = _.get(groupPriceByUsageTypeAndSize, [
+                  usage,
+                  planType,
+                  currencyCode,
+                  size,
+                ])
+              } else {
+                plan = plans.find(p => p.planCode === planCode)
+              }
 
               const response = {
                 next: {

+ 9 - 0
services/web/types/subscription/plan.ts

@@ -28,3 +28,12 @@ export type Plan = {
   planCode: string
   price_in_cents: number
 }
+
+export type PriceForDisplayData = {
+  totalForDisplay: string
+  totalAsNumber: number
+  subtotal: string
+  tax: string
+  includesTax: boolean
+  perUserDisplayPrice?: string
+}