Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const Controller: React.FC<BillingControlsProps> = ({
return (
<TeamPlanController
seats={seats}
newPlan={newPlan}
setFormValue={setFormValue}
setSelectedPlan={setSelectedPlan}
register={register}
Expand All @@ -41,10 +42,26 @@ const Controller: React.FC<BillingControlsProps> = ({
)
} else if (newPlan?.isSentryPlan) {
return (
<SentryPlanController seats={seats} register={register} errors={errors} />
<SentryPlanController
seats={seats}
newPlan={newPlan}
setFormValue={setFormValue}
setSelectedPlan={setSelectedPlan}
register={register}
errors={errors}
/>
)
}
return <ProPlanController seats={seats} register={register} errors={errors} />
return (
<ProPlanController
seats={seats}
newPlan={newPlan}
setFormValue={setFormValue}
setSelectedPlan={setSelectedPlan}
register={register}
errors={errors}
/>
)
}

export default Controller
Original file line number Diff line number Diff line change
Expand Up @@ -134,19 +134,26 @@ describe('BillingOptions', () => {
)

const mockSetFormValue = vi.fn()
const mockSetSelectedPlan = vi.fn()
const user = userEvent.setup()

return { user, mockSetFormValue }
return { user, mockSetFormValue, mockSetSelectedPlan }
}

describe('when rendered', () => {
describe('planString is set to a monthly plan', () => {
it('renders monthly button as "selected"', async () => {
setup()

render(<BillingOptions />, {
wrapper,
})
const { mockSetFormValue, mockSetSelectedPlan } = setup()

render(
<BillingOptions
setFormValue={mockSetFormValue}
setSelectedPlan={mockSetSelectedPlan}
/>,
{
wrapper,
}
)

const annualBtn = screen.queryByTestId('radio-annual')
expect(annualBtn).not.toBeInTheDocument()
Expand All @@ -157,11 +164,17 @@ describe('BillingOptions', () => {
})

it('renders correct pricing scheme', async () => {
setup()

render(<BillingOptions />, {
wrapper,
})
const { mockSetFormValue, mockSetSelectedPlan } = setup()

render(
<BillingOptions
setFormValue={mockSetFormValue}
setSelectedPlan={mockSetSelectedPlan}
/>,
{
wrapper,
}
)

const cost = await screen.findByText(/\$12/)
expect(cost).toBeInTheDocument()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,83 @@
import { useState } from 'react'
import { UseFormSetValue } from 'react-hook-form'
import { useParams } from 'react-router-dom'

import { useAvailablePlans } from 'services/account/useAvailablePlans'
import { findProPlans } from 'shared/utils/billing'
import {
IndividualPlan,
useAvailablePlans,
} from 'services/account/useAvailablePlans'
import { usePlanData } from 'services/account/usePlanData'
import { BillingRate, findProPlans } from 'shared/utils/billing'
import { RadioTileGroup } from 'ui/RadioTileGroup'

import { TimePeriods } from '../../../constants'
import { OptionPeriod, TimePeriods } from '../../../constants'
import { UpgradeFormFields } from '../../../UpgradeForm'

const BillingControls: React.FC = () => {
interface BillingControlsProps {
newPlan?: IndividualPlan
setFormValue: UseFormSetValue<UpgradeFormFields>
setSelectedPlan: (plan?: IndividualPlan) => void
}

const BillingControls: React.FC<BillingControlsProps> = ({
newPlan,
setFormValue,
setSelectedPlan,
}) => {
const { provider, owner } = useParams<{ provider: string; owner: string }>()
const { data: plans } = useAvailablePlans({ provider, owner })
const { proPlanMonth } = findProPlans({ plans })
const { proPlanMonth, proPlanYear } = findProPlans({ plans })
const { data: planData } = usePlanData({
provider,
owner,
})

const currentPlanBillingRate = planData?.plan?.billingRate
// Use newPlan's billing rate if available (preserves selection when switching plan types)
// Fall back to current plan's billing rate for initial default
const initialBillingRate = newPlan?.billingRate ?? currentPlanBillingRate
const [option, setOption] = useState<OptionPeriod>(() =>
initialBillingRate === BillingRate.MONTHLY
? TimePeriods.MONTHLY
: TimePeriods.ANNUAL
)

const baseUnitPrice =
option === TimePeriods.MONTHLY
? proPlanMonth?.baseUnitPrice
: proPlanYear?.baseUnitPrice
const billingRate =
option === TimePeriods.MONTHLY
? proPlanMonth?.billingRate
: proPlanYear?.billingRate

return (
<div className="flex w-fit flex-col gap-2">
<h3 className="font-semibold">Step 2: Choose a billing cycle</h3>
<div className="inline-flex items-center gap-2">
<RadioTileGroup value={TimePeriods.MONTHLY}>
<RadioTileGroup
value={option}
onValueChange={(value: OptionPeriod) => {
if (value === TimePeriods.ANNUAL) {
setFormValue('newPlan', proPlanYear)
setSelectedPlan(proPlanYear)
} else {
setFormValue('newPlan', proPlanMonth)
setSelectedPlan(proPlanMonth)
}

setOption(value)
}}
>
{currentPlanBillingRate === BillingRate.ANNUALLY && (
<RadioTileGroup.Item
value={TimePeriods.ANNUAL}
className="w-32"
data-testid="radio-annual"
>
<RadioTileGroup.Label>{TimePeriods.ANNUAL}</RadioTileGroup.Label>
</RadioTileGroup.Item>
)}
<RadioTileGroup.Item
value={TimePeriods.MONTHLY}
className="w-32"
Expand All @@ -25,8 +87,8 @@ const BillingControls: React.FC = () => {
</RadioTileGroup.Item>
</RadioTileGroup>
<p>
<span className="font-semibold">${proPlanMonth?.baseUnitPrice}</span>{' '}
per seat/month, billed {proPlanMonth?.billingRate}
<span className="font-semibold">${baseUnitPrice}</span> per
seat/month, billed {billingRate}
</p>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { Fragment } from 'react'
import { useParams } from 'react-router-dom'

import { MONTHS_PER_YEAR } from 'pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails'
import { useAccountDetails } from 'services/account/useAccountDetails'
import { useAvailablePlans } from 'services/account/useAvailablePlans'
import {
IndividualPlan,
useAvailablePlans,
} from 'services/account/useAvailablePlans'
import { Provider } from 'shared/api/helpers'
import {
BillingRate,
findProPlans,
formatNumberToUSD,
getNextBillingDate,
Expand All @@ -15,13 +20,14 @@ import {
} from 'shared/utils/upgradeForm'

interface PriceCalloutProps {
newPlan?: IndividualPlan
seats: number
}

const PriceCallout: React.FC<PriceCalloutProps> = ({ seats }) => {
const PriceCallout: React.FC<PriceCalloutProps> = ({ newPlan, seats }) => {
const { provider, owner } = useParams<{ provider: Provider; owner: string }>()
const { data: plans } = useAvailablePlans({ provider, owner })
const { proPlanMonth } = findProPlans({ plans })
const { proPlanMonth, proPlanYear } = findProPlans({ plans })
const perMonthPrice = calculatePriceProPlan({
seats,
baseUnitPrice: proPlanMonth?.baseUnitPrice,
Expand All @@ -30,10 +36,46 @@ const PriceCallout: React.FC<PriceCalloutProps> = ({ seats }) => {
const { data: accountDetails } = useAccountDetails({ provider, owner })
const nextBillingDate = getNextBillingDate(accountDetails)

const perYearPrice = calculatePriceProPlan({
seats,
baseUnitPrice: proPlanYear?.baseUnitPrice,
})
const isPerYear = newPlan?.billingRate === BillingRate.ANNUALLY

if (seats < MIN_NB_SEATS_PRO) {
return null
}

if (isPerYear) {
return (
<div className="bg-ds-gray-primary p-4">
<p className="pb-3">
<span className="font-semibold">
{formatNumberToUSD(perYearPrice)}
</span>
/month billed annually at{' '}
{formatNumberToUSD(perYearPrice * MONTHS_PER_YEAR)}
</p>
<p>
&#127881; You{' '}
<span className="font-semibold">
save{' '}
{formatNumberToUSD(
(perMonthPrice - perYearPrice) * MONTHS_PER_YEAR
)}
</span>{' '}
with annual billing
{nextBillingDate && (
<Fragment>
,<span className="font-semibold"> next billing date</span> is{' '}
{nextBillingDate}
</Fragment>
)}
</p>
</div>
)
}

return (
<div className="bg-ds-gray-primary p-4">
<p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ describe('ProPlanController', () => {
describe('when the user has a pro plan monthly', () => {
const props = {
setFormValue: vi.fn(),
setSelectedPlan: vi.fn(),
register: vi.fn(),
newPlan: proPlanMonth,
seats: 10,
Expand Down Expand Up @@ -315,6 +316,7 @@ describe('ProPlanController', () => {
describe('when the user has a pro plan yearly', () => {
const props = {
setFormValue: vi.fn(),
setSelectedPlan: vi.fn(),
register: vi.fn(),
newPlan: proPlanYear,
seats: 13,
Expand All @@ -329,21 +331,21 @@ describe('ProPlanController', () => {
expect(optionBtn).toBeInTheDocument()
})

it('does not render annual option button', async () => {
it('renders annual option button when current plan is annual', async () => {
setup({ planValue: proPlanYear, monthlyPlan: false })
render(<ProPlanController {...props} />, { wrapper: wrapper() })

const optionBtn = screen.queryByTestId('radio-annual')
expect(optionBtn).not.toBeInTheDocument()
const optionBtn = await screen.findByTestId('radio-annual')
expect(optionBtn).toBeInTheDocument()
})

it('has the price for the month', async () => {
it('has the price for the year', async () => {
setup({ planValue: proPlanYear, monthlyPlan: false })
render(<ProPlanController {...props} />, { wrapper: wrapper() })

const price = await screen.findByText(/\$156/)
const price = await screen.findByText(/\$130/)
expect(price).toBeInTheDocument()
const monthly = await screen.findByText(/billed monthly/)
const monthly = await screen.findByText(/\/month billed annually at/)
expect(monthly).toBeInTheDocument()
})
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { UseFormRegister } from 'react-hook-form'
import { UseFormRegister, UseFormSetValue } from 'react-hook-form'

import { IndividualPlan } from 'services/account/useAvailablePlans'
import { MIN_NB_SEATS_PRO } from 'shared/utils/upgradeForm'
import { Card } from 'ui/Card'
import TextInput from 'ui/TextInput'
Expand All @@ -12,8 +13,11 @@ import { UpgradeFormFields } from '../../UpgradeForm'

interface ProPlanControllerProps {
seats: number
newPlan?: IndividualPlan

register: UseFormRegister<UpgradeFormFields>
setFormValue: UseFormSetValue<UpgradeFormFields>
setSelectedPlan: (plan?: IndividualPlan) => void
errors?: {
seats?: {
message?: string
Expand All @@ -23,14 +27,21 @@ interface ProPlanControllerProps {

const ProPlanController: React.FC<ProPlanControllerProps> = ({
seats,
newPlan,
setFormValue,
setSelectedPlan,
register,
errors,
}) => {
return (
<>
<Card.Content>
<div className="flex flex-col gap-2">
<BillingOptions />
<BillingOptions
newPlan={newPlan}
setFormValue={setFormValue}
setSelectedPlan={setSelectedPlan}
/>
</div>
</Card.Content>
<hr />
Expand All @@ -55,7 +66,7 @@ const ProPlanController: React.FC<ProPlanControllerProps> = ({
</div>
</Card.Content>
<Card.Content>
<PriceCallout seats={seats} />
<PriceCallout newPlan={newPlan} seats={seats} />
{errors?.seats && (
<p className="rounded-md bg-ds-error-quinary p-3 text-ds-error-nonary">
{errors?.seats?.message}
Expand Down
Loading
Loading