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
This commit is contained in:
Jessica Lawshe 2023-02-21 09:24:49 -06:00 committed by Copybot
parent cad3660f0b
commit 124306d7ac
15 changed files with 685 additions and 49 deletions

38
package-lock.json generated
View file

@ -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",

View file

@ -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": "",

View file

@ -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>
)
}

View file

@ -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 }} />{' '}

View file

@ -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,

View file

@ -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
}

View file

@ -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",

View file

@ -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",

View file

@ -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',
})
fireEvent.click(buttonGroupModal)
const modal = await screen.findByRole('dialog')
const educationInput = within(modal).getByLabelText(
'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
)
const educationInput = within(modal).getByLabelText(educationInputLabel)
fireEvent.click(educationInput)
within(modal).getByText(discountAppliedText)
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,
})
})
it('changes the price when options change', async function () {
renderActiveSubscription(annualActiveSubscription)
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)
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
})
})
})

View file

@ -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,
},
},
},
},
}

View file

@ -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: [],

View file

@ -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',
},
],
}

View file

@ -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: {

View file

@ -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
}