1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-27 00:19:39 +01:00

chore: PAYG billing (#8743)

https://linear.app/unleash/issue/CTO-95/unleash-billing-page-for-enterprise-payg

Adds support for PAYG in Unleash's billing page.

Includes some refactoring, like splitting Pro and PAYG into different
details components. We're now also relying on shared billing-related
constants (see `BillingPlan.tsx`). This should make it much easier to
change any of these values in the future. I already changed a few that
were static / wrongly relying on instanceStatus.seats (we decided we're
not doing that for now).


![image](https://github.com/user-attachments/assets/97a5a420-a4f6-4b6c-93d6-3fffddbacbc7)
This commit is contained in:
Nuno Góis 2024-11-14 11:29:05 +00:00 committed by GitHub
parent 54444a395c
commit 395a4b6be3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 431 additions and 272 deletions

View File

@ -10,13 +10,8 @@ import { BillingHistory } from './BillingHistory/BillingHistory';
import useInvoices from 'hooks/api/getters/useInvoices/useInvoices';
export const Billing = () => {
const {
instanceStatus,
isBilling,
refetchInstanceStatus,
refresh,
loading,
} = useInstanceStatus();
const { isBilling, refetchInstanceStatus, refresh, loading } =
useInstanceStatus();
const { invoices } = useInvoices();
useEffect(() => {
@ -35,9 +30,7 @@ export const Billing = () => {
show={
<PermissionGuard permissions={ADMIN}>
<>
<BillingDashboard
instanceStatus={instanceStatus!}
/>
<BillingDashboard />
<BillingHistory data={invoices} />
</>
</PermissionGuard>

View File

@ -1,20 +1,12 @@
import { Grid } from '@mui/material';
import type { IInstanceStatus } from 'interfaces/instance';
import type { VFC } from 'react';
import { BillingInformation } from './BillingInformation/BillingInformation';
import { BillingPlan } from './BillingPlan/BillingPlan';
interface IBillingDashboardProps {
instanceStatus: IInstanceStatus;
}
export const BillingDashboard: VFC<IBillingDashboardProps> = ({
instanceStatus,
}) => {
export const BillingDashboard = () => {
return (
<Grid container spacing={4}>
<BillingInformation instanceStatus={instanceStatus} />
<BillingPlan instanceStatus={instanceStatus} />
<BillingInformation />
<BillingPlan />
</Grid>
);
};

View File

@ -1,8 +1,9 @@
import type { FC } from 'react';
import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
import { BillingInformationButton } from './BillingInformationButton/BillingInformationButton';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { type IInstanceStatus, InstanceState } from 'interfaces/instance';
import { InstanceState } from 'interfaces/instance';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
const StyledInfoBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(4),
@ -28,13 +29,22 @@ const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(2.5)} 0`,
borderColor: theme.palette.divider,
}));
interface IBillingInformationProps {
instanceStatus: IInstanceStatus;
}
export const BillingInformation: FC<IBillingInformationProps> = ({
instanceStatus,
}) => {
export const BillingInformation = () => {
const { instanceStatus } = useInstanceStatus();
const {
uiConfig: { billing },
} = useUiConfig();
const isPAYG = billing === 'pay-as-you-go';
if (!instanceStatus)
return (
<Grid item xs={12} md={5}>
<StyledInfoBox data-loading sx={{ flex: 1, height: '400px' }} />
</Grid>
);
const plan = `${instanceStatus.plan}${isPAYG ? ' Pay-as-You-Go' : ''}`;
const inactive = instanceStatus.state !== InstanceState.ACTIVE;
return (
@ -58,7 +68,9 @@ export const BillingInformation: FC<IBillingInformationProps> = ({
</StyledInfoLabel>
<StyledDivider />
<StyledInfoLabel>
<a href='mailto:support@getunleash.io?subject=PRO plan clarifications'>
<a
href={`mailto:support@getunleash.io?subject=${plan} plan clarifications`}
>
Get in touch with us
</a>{' '}
for any clarification

View File

@ -0,0 +1,23 @@
import { type IInstanceStatus, InstancePlan } from 'interfaces/instance';
import { BillingDetailsPro } from './BillingDetailsPro';
import { BillingDetailsPAYG } from './BillingDetailsPAYG';
interface IBillingDetailsProps {
instanceStatus: IInstanceStatus;
isPAYG: boolean;
}
export const BillingDetails = ({
instanceStatus,
isPAYG,
}: IBillingDetailsProps) => {
if (isPAYG) {
return <BillingDetailsPAYG instanceStatus={instanceStatus} />;
}
if (instanceStatus.plan === InstancePlan.PRO) {
return <BillingDetailsPro instanceStatus={instanceStatus} />;
}
return null;
};

View File

@ -0,0 +1,103 @@
import { Link } from 'react-router-dom';
import { Divider, Grid, styled, Typography } from '@mui/material';
import { GridRow } from 'component/common/GridRow/GridRow';
import { GridCol } from 'component/common/GridCol/GridCol';
import { GridColLink } from './GridColLink/GridColLink';
import type { IInstanceStatus } from 'interfaces/instance';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import {
BILLING_PAYG_DEFAULT_MINIMUM_SEATS,
BILLING_PAYG_USER_PRICE,
} from './BillingPlan';
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(3)} 0`,
}));
interface IBillingDetailsPAYGProps {
instanceStatus: IInstanceStatus;
}
export const BillingDetailsPAYG = ({
instanceStatus,
}: IBillingDetailsPAYGProps) => {
const { users, loading } = useUsers();
const eligibleUsers = users.filter((user) => user.email);
const minSeats =
instanceStatus.minSeats ?? BILLING_PAYG_DEFAULT_MINIMUM_SEATS;
const billableUsers = Math.max(eligibleUsers.length, minSeats);
const usersCost = BILLING_PAYG_USER_PRICE * billableUsers;
const totalCost = usersCost;
if (loading) return null;
return (
<>
<Grid container>
<GridRow
sx={(theme) => ({
marginBottom: theme.spacing(1.5),
})}
>
<GridCol vertical>
<Typography>
<strong>Paid members</strong>
<GridColLink>
<Link to='/admin/users'>
{eligibleUsers.length} assigned of{' '}
{minSeats} minimum
</Link>
</GridColLink>
</Typography>
<StyledInfoLabel>
${BILLING_PAYG_USER_PRICE}/month per paid member
</StyledInfoLabel>
</GridCol>
<GridCol>
<Typography
sx={(theme) => ({
fontSize: theme.fontSizes.mainHeader,
})}
>
${usersCost.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
</Grid>
<StyledDivider />
<Grid container>
<GridRow>
<GridCol>
<Typography
sx={(theme) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.mainHeader,
})}
>
Total
</Typography>
</GridCol>
<GridCol>
<Typography
sx={(theme) => ({
fontWeight: theme.fontWeight.bold,
fontSize: '2rem',
})}
>
${totalCost.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
</Grid>
</>
);
};

View File

@ -0,0 +1,193 @@
import { Link } from 'react-router-dom';
import { Divider, Grid, styled, Typography } from '@mui/material';
import CheckIcon from '@mui/icons-material/Check';
import { GridRow } from 'component/common/GridRow/GridRow';
import { GridCol } from 'component/common/GridCol/GridCol';
import { GridColLink } from './GridColLink/GridColLink';
import type { IInstanceStatus } from 'interfaces/instance';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useMemo } from 'react';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { useTrafficDataEstimation } from 'hooks/useTrafficData';
import {
BILLING_INCLUDED_REQUESTS,
BILLING_PLAN_PRICES,
BILLING_PRO_DEFAULT_INCLUDED_SEATS,
BILLING_PRO_USER_PRICE,
} from './BillingPlan';
import { useInstanceTrafficMetrics } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
const StyledCheckIcon = styled(CheckIcon)(({ theme }) => ({
fontSize: '1rem',
marginRight: theme.spacing(1),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(3)} 0`,
}));
interface IBillingDetailsProProps {
instanceStatus: IInstanceStatus;
}
export const BillingDetailsPro = ({
instanceStatus,
}: IBillingDetailsProProps) => {
const { users, loading } = useUsers();
const {
currentPeriod,
toChartData,
toTrafficUsageSum,
endpointsInfo,
getDayLabels,
calculateOverageCost,
} = useTrafficDataEstimation();
const eligibleUsers = users.filter((user) => user.email);
const planPrice = BILLING_PLAN_PRICES[instanceStatus.plan];
const seats = BILLING_PRO_DEFAULT_INCLUDED_SEATS;
const freeAssigned = Math.min(eligibleUsers.length, seats);
const paidAssigned = eligibleUsers.length - freeAssigned;
const paidAssignedPrice = BILLING_PRO_USER_PRICE * paidAssigned;
const includedTraffic = BILLING_INCLUDED_REQUESTS;
const traffic = useInstanceTrafficMetrics(currentPeriod.key);
const overageCost = useMemo(() => {
if (!includedTraffic) {
return 0;
}
const trafficData = toChartData(
getDayLabels(currentPeriod.dayCount),
traffic,
endpointsInfo,
);
const totalTraffic = toTrafficUsageSum(trafficData);
return calculateOverageCost(totalTraffic, includedTraffic);
}, [includedTraffic, traffic, currentPeriod, endpointsInfo]);
const totalCost = planPrice + paidAssignedPrice + overageCost;
if (loading) return null;
return (
<>
<Grid container>
<GridRow
sx={(theme) => ({
marginBottom: theme.spacing(1.5),
})}
>
<GridCol vertical>
<Typography>
<strong>Included members</strong>
<GridColLink>
<Link to='/admin/users'>
{freeAssigned} of {seats} assigned
</Link>
</GridColLink>
</Typography>
<StyledInfoLabel>
You have {seats} team members included in your PRO
plan
</StyledInfoLabel>
</GridCol>
<GridCol>
<StyledCheckIcon />
<Typography variant='body2'>included</Typography>
</GridCol>
</GridRow>
<GridRow
sx={(theme) => ({
marginBottom: theme.spacing(1.5),
})}
>
<GridCol vertical>
<Typography>
<strong>Paid members</strong>
<GridColLink>
<Link to='/admin/users'>
{paidAssigned} assigned
</Link>
</GridColLink>
</Typography>
<StyledInfoLabel>
${BILLING_PRO_USER_PRICE}/month per paid member
</StyledInfoLabel>
</GridCol>
<GridCol>
<Typography
sx={(theme) => ({
fontSize: theme.fontSizes.mainHeader,
})}
>
${paidAssignedPrice.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
<ConditionallyRender
condition={overageCost > 0}
show={
<GridRow>
<GridCol vertical>
<Typography>
<strong>Accrued traffic charges</strong>
<GridColLink>
<Link to='/admin/network/data-usage'>
view details
</Link>
</GridColLink>
</Typography>
<StyledInfoLabel>
$5 dollar per 1 million started above
included data
</StyledInfoLabel>
</GridCol>
<GridCol>
<Typography
sx={(theme) => ({
fontSize: theme.fontSizes.mainHeader,
})}
>
${overageCost.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
}
/>
</Grid>
<StyledDivider />
<Grid container>
<GridRow>
<GridCol>
<Typography
sx={(theme) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.mainHeader,
})}
>
Total
</Typography>
</GridCol>
<GridCol>
<Typography
sx={(theme) => ({
fontWeight: theme.fontWeight.bold,
fontSize: '2rem',
})}
>
${totalCost.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
</Grid>
</>
);
};

View File

@ -1,23 +1,23 @@
import type { FC } from 'react';
import { useMemo } from 'react';
import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
import { Link } from 'react-router-dom';
import CheckIcon from '@mui/icons-material/Check';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { Alert, Grid, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
type IInstanceStatus,
InstanceState,
InstancePlan,
} from 'interfaces/instance';
import { InstanceState, InstancePlan } from 'interfaces/instance';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { trialHasExpired, isTrialInstance } from 'utils/instanceTrial';
import { GridRow } from 'component/common/GridRow/GridRow';
import { GridCol } from 'component/common/GridCol/GridCol';
import { Badge } from 'component/common/Badge/Badge';
import { GridColLink } from './GridColLink/GridColLink';
import { useTrafficDataEstimation } from 'hooks/useTrafficData';
import { useInstanceTrafficMetrics } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
import { BillingDetails } from './BillingDetails';
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
export const BILLING_PLAN_PRICES: Record<string, number> = {
[InstancePlan.PRO]: 80,
};
export const BILLING_PAYG_USER_PRICE = 75;
export const BILLING_PAYG_DEFAULT_MINIMUM_SEATS = 5;
export const BILLING_PRO_USER_PRICE = 15;
export const BILLING_PRO_DEFAULT_INCLUDED_SEATS = 5;
export const BILLING_INCLUDED_REQUESTS = 53_000_000;
const StyledPlanBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(2.5),
@ -29,20 +29,19 @@ const StyledPlanBox = styled('aside')(({ theme }) => ({
},
}));
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
const StyledPlanSpan = styled('span')(({ theme }) => ({
fontSize: '3.25rem',
lineHeight: 1,
color: theme.palette.primary.main,
fontWeight: 800,
marginRight: theme.spacing(1.5),
}));
const StyledPAYGSpan = styled('span')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
}));
const StyledTrialSpan = styled('span')(({ theme }) => ({
marginLeft: theme.spacing(1.5),
fontWeight: theme.fontWeight.bold,
}));
@ -61,74 +60,26 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
},
}));
const StyledCheckIcon = styled(CheckIcon)(({ theme }) => ({
fontSize: '1rem',
marginRight: theme.spacing(1),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(3)} 0`,
}));
interface IBillingPlanProps {
instanceStatus: IInstanceStatus;
}
const proPlanIncludedRequests = 53_000_000;
export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
const { users, loading } = useUsers();
const expired = trialHasExpired(instanceStatus);
const { isPro } = useUiConfig();
export const BillingPlan = () => {
const {
currentPeriod,
toChartData,
toTrafficUsageSum,
endpointsInfo,
getDayLabels,
calculateOverageCost,
} = useTrafficDataEstimation();
uiConfig: { billing },
} = useUiConfig();
const { instanceStatus } = useInstanceStatus();
const eligibleUsers = users.filter((user: any) => user.email);
const isPAYG = billing === 'pay-as-you-go';
const price = {
[InstancePlan.PRO]: 80,
[InstancePlan.COMPANY]: 0,
[InstancePlan.TEAM]: 0,
[InstancePlan.ENTERPRISE]: 0,
[InstancePlan.UNKNOWN]: 0,
user: 15,
};
const planPrice = price[instanceStatus.plan];
const seats = instanceStatus.seats ?? 5;
const freeAssigned = Math.min(eligibleUsers.length, seats);
const paidAssigned = eligibleUsers.length - freeAssigned;
const paidAssignedPrice = price.user * paidAssigned;
const includedTraffic = isPro() ? proPlanIncludedRequests : 0;
const traffic = useInstanceTrafficMetrics(currentPeriod.key);
const overageCost = useMemo(() => {
if (!includedTraffic) {
return 0;
}
const trafficData = toChartData(
getDayLabels(currentPeriod.dayCount),
traffic,
endpointsInfo,
if (!instanceStatus)
return (
<Grid item xs={12} md={7}>
<StyledPlanBox data-loading sx={{ flex: 1, height: '400px' }} />
</Grid>
);
const totalTraffic = toTrafficUsageSum(trafficData);
return calculateOverageCost(totalTraffic, includedTraffic);
}, [includedTraffic, traffic, currentPeriod, endpointsInfo]);
const totalCost = planPrice + paidAssignedPrice + overageCost;
const expired = trialHasExpired(instanceStatus);
const planPrice = BILLING_PLAN_PRICES[instanceStatus.plan] ?? 0;
const plan = `${instanceStatus.plan}${isPAYG ? ' Pay-as-You-Go' : ''}`;
const inactive = instanceStatus.state !== InstanceState.ACTIVE;
if (loading) return null;
return (
<Grid item xs={12} md={7}>
<StyledPlanBox>
@ -139,7 +90,9 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
After you have sent your billing information, your
instance will be upgraded - you don't have to do
anything.{' '}
<a href='mailto:support@getunleash.io?subject=PRO plan clarifications'>
<a
href={`mailto:support@getunleash.io?subject=${plan} plan clarifications`}
>
Get in touch with us
</a>{' '}
for any clarification
@ -147,10 +100,11 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
}
/>
<Badge color='success'>Current plan</Badge>
<Grid container>
<GridRow
sx={(theme) => ({ marginBottom: theme.spacing(3) })}
>
<Grid
container
sx={(theme) => ({ marginBottom: theme.spacing(3) })}
>
<GridRow>
<GridCol>
<StyledPlanSpan>
{instanceStatus.plan}
@ -185,134 +139,18 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
/>
</GridCol>
</GridRow>
<GridRow>
<ConditionallyRender
condition={isPAYG}
show={
<StyledPAYGSpan>Pay-as-You-Go</StyledPAYGSpan>
}
/>
</GridRow>
</Grid>
<ConditionallyRender
condition={Boolean(
instanceStatus.plan === InstancePlan.PRO,
)}
show={
<>
<Grid container>
<GridRow
sx={(theme) => ({
marginBottom: theme.spacing(1.5),
})}
>
<GridCol vertical>
<Typography>
<strong>Included members</strong>
<GridColLink>
<Link to='/admin/users'>
{freeAssigned} of 5 assigned
</Link>
</GridColLink>
</Typography>
<StyledInfoLabel>
You have 5 team members included in
your PRO plan
</StyledInfoLabel>
</GridCol>
<GridCol>
<StyledCheckIcon />
<Typography variant='body2'>
included
</Typography>
</GridCol>
</GridRow>
<GridRow
sx={(theme) => ({
marginBottom: theme.spacing(1.5),
})}
>
<GridCol vertical>
<Typography>
<strong>Paid members</strong>
<GridColLink>
<Link to='/admin/users'>
{paidAssigned} assigned
</Link>
</GridColLink>
</Typography>
<StyledInfoLabel>
$15/month per paid member
</StyledInfoLabel>
</GridCol>
<GridCol>
<Typography
sx={(theme) => ({
fontSize:
theme.fontSizes.mainHeader,
})}
>
${paidAssignedPrice.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
<ConditionallyRender
condition={overageCost > 0}
show={
<GridRow>
<GridCol vertical>
<Typography>
<strong>
Accrued traffic charges
</strong>
<GridColLink>
<Link to='/admin/network/data-usage'>
view details
</Link>
</GridColLink>
</Typography>
<StyledInfoLabel>
$5 dollar per 1 million
started above included data
</StyledInfoLabel>
</GridCol>
<GridCol>
<Typography
sx={(theme) => ({
fontSize:
theme.fontSizes
.mainHeader,
})}
>
${overageCost.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
}
/>
</Grid>
<StyledDivider />
<Grid container>
<GridRow>
<GridCol>
<Typography
sx={(theme) => ({
fontWeight:
theme.fontWeight.bold,
fontSize:
theme.fontSizes.mainHeader,
})}
>
Total
</Typography>
</GridCol>
<GridCol>
<Typography
sx={(theme) => ({
fontWeight:
theme.fontWeight.bold,
fontSize: '2rem',
})}
>
${totalCost.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
</Grid>
</>
}
<BillingDetails
instanceStatus={instanceStatus}
isPAYG={isPAYG}
/>
</StyledPlanBox>
</Grid>

View File

@ -2,6 +2,7 @@ import type { VFC } from 'react';
import { Alert } from '@mui/material';
import { useUsersPlan } from 'hooks/useUsersPlan';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { BILLING_PRO_USER_PRICE } from 'component/admin/billing/BillingDashboard/BillingPlan/BillingPlan';
export const SeatCostWarning: VFC = () => {
const { users } = useUsers();
@ -19,7 +20,8 @@ export const SeatCostWarning: VFC = () => {
<p>
<strong>Heads up!</strong> You are exceeding your allocated free
members included in your plan ({planUsers.length} of {seats}).
Creating this user will add <strong>$15/month</strong> to your
Creating this user will add{' '}
<strong>${BILLING_PRO_USER_PRICE}/month</strong> to your
invoice, starting with your next payment.
</p>
</Alert>

View File

@ -4,6 +4,13 @@ import GitHub from '@mui/icons-material/GitHub';
import Launch from '@mui/icons-material/Launch';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useUiFlag } from 'hooks/useUiFlag';
import {
BILLING_PAYG_DEFAULT_MINIMUM_SEATS,
BILLING_PAYG_USER_PRICE,
BILLING_PLAN_PRICES,
BILLING_PRO_DEFAULT_INCLUDED_SEATS,
} from 'component/admin/billing/BillingDashboard/BillingPlan/BillingPlan';
import { InstancePlan } from 'interfaces/instance';
const StyledDemoDialog = styled(DemoDialog)(({ theme }) => ({
'& .MuiDialog-paper': {
@ -132,10 +139,11 @@ export const DemoDialogPlans = ({ open, onClose }: IDemoDialogPlansProps) => {
</Typography>
<div>
<Typography variant='h6' fontWeight='normal'>
$75 per user/month
${BILLING_PAYG_USER_PRICE} per user/month
</Typography>
<Typography variant='body2'>
5 users minimum
{BILLING_PAYG_DEFAULT_MINIMUM_SEATS} users
minimum
</Typography>
</div>
<Button
@ -166,10 +174,11 @@ export const DemoDialogPlans = ({ open, onClose }: IDemoDialogPlansProps) => {
</Typography>
<div>
<Typography variant='h6' fontWeight='normal'>
$80/month
${BILLING_PLAN_PRICES[InstancePlan.PRO]}/month
</Typography>
<Typography variant='body2'>
includes 5 seats
includes {BILLING_PRO_DEFAULT_INCLUDED_SEATS}{' '}
seats
</Typography>
</div>
<Button

View File

@ -1,6 +1,7 @@
import type { FC } from 'react';
import { Box, Card, styled, Typography } from '@mui/material';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import { BILLING_PRO_DEFAULT_INCLUDED_SEATS } from 'component/admin/billing/BillingDashboard/BillingPlan/BillingPlan';
type OrderEnvironmentsDialogPricingProps = {
pricingOptions: Array<{ environments: number; price: number }>;
@ -61,8 +62,9 @@ export const OrderEnvironmentsDialogPricing: FC<
))}
<StyledExtraText>
<Typography variant='body2' color='white'>
With Pro, there is a minimum of 5 users, meaning an additional
environment will cost at least $50 per month.
With Pro, there is a minimum of{' '}
{BILLING_PRO_DEFAULT_INCLUDED_SEATS} users, meaning an
additional environment will cost at least $50 per month.
</Typography>
</StyledExtraText>
</StyledContainer>

View File

@ -2,19 +2,16 @@ import { testServerRoute, testServerSetup } from '../../../../utils/testServer';
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { UserSeats } from './UserSeats';
import { BILLING_PRO_DEFAULT_INCLUDED_SEATS } from 'component/admin/billing/BillingDashboard/BillingPlan/BillingPlan';
const server = testServerSetup();
const user1 = {};
const user2 = {};
const setupApiWithSeats = (seats: number | undefined) => {
const setupApi = () => {
testServerRoute(server, '/api/admin/user-admin', {
users: [user1, user2],
});
testServerRoute(server, '/api/instance/status', {
plan: 'Enterprise',
seats,
});
testServerRoute(server, '/api/admin/ui-config', {
flags: {
UNLEASH_CLOUD: true,
@ -23,18 +20,12 @@ const setupApiWithSeats = (seats: number | undefined) => {
};
test('User seats display when seats are available', async () => {
setupApiWithSeats(20);
setupApi();
render(<UserSeats />);
await screen.findByText('User seats');
await screen.findByText('2/20 seats used');
});
test('User seats does not display when seats are not available', async () => {
setupApiWithSeats(undefined);
render(<UserSeats />);
expect(screen.queryByText('User seats')).not.toBeInTheDocument();
await screen.findByText(
`2/${BILLING_PRO_DEFAULT_INCLUDED_SEATS} seats used`,
);
});

View File

@ -2,7 +2,7 @@ import LicenseIcon from '@mui/icons-material/ReceiptLongOutlined';
import { Box, styled, Typography } from '@mui/material';
import LinearProgress from '@mui/material/LinearProgress';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
import { BILLING_PRO_DEFAULT_INCLUDED_SEATS } from 'component/admin/billing/BillingDashboard/BillingPlan/BillingPlan';
const SeatsUsageBar = styled(LinearProgress)(({ theme }) => ({
marginTop: theme.spacing(0.5),
@ -32,8 +32,7 @@ const SeatsUsageText = styled(Box)(({ theme }) => ({
export const UserSeats = () => {
const { users } = useUsers();
const { instanceStatus } = useInstanceStatus();
const seats = instanceStatus?.seats;
const seats = BILLING_PRO_DEFAULT_INCLUDED_SEATS;
if (typeof seats === 'number') {
const percentageSeats = Math.floor((users.length / seats) * 100);

View File

@ -37,7 +37,9 @@ export const useInstanceStatus = (): IUseInstanceStatusOutput => {
instanceStatus: data,
refetchInstanceStatus: refetch,
refresh,
isBilling: billingPlans.includes(data?.plan ?? InstancePlan.UNKNOWN),
isBilling:
uiConfig.billing === 'pay-as-you-go' ||
billingPlans.includes(data?.plan ?? InstancePlan.UNKNOWN),
loading,
error,
};

View File

@ -2,7 +2,7 @@ import type { IUser } from 'interfaces/user';
import { useMemo } from 'react';
import { useInstanceStatus } from './api/getters/useInstanceStatus/useInstanceStatus';
import { InstancePlan } from 'interfaces/instance';
import useUiConfig from './api/getters/useUiConfig/useUiConfig';
import { BILLING_PRO_DEFAULT_INCLUDED_SEATS } from 'component/admin/billing/BillingDashboard/BillingPlan/BillingPlan';
export interface IUsersPlanOutput {
planUsers: IUser[];
@ -13,10 +13,9 @@ export interface IUsersPlanOutput {
export const useUsersPlan = (users: IUser[]): IUsersPlanOutput => {
const { instanceStatus } = useInstanceStatus();
const { uiConfig } = useUiConfig();
const isBillingUsers = Boolean(instanceStatus?.plan === InstancePlan.PRO);
const seats = instanceStatus?.seats ?? 5;
const seats = BILLING_PRO_DEFAULT_INCLUDED_SEATS;
const planUsers = useMemo(
() => calculatePaidUsers(users, isBillingUsers, seats),

View File

@ -6,6 +6,7 @@ export interface IInstanceStatus {
billingCenter?: string;
state?: InstanceState;
seats?: number;
minSeats?: number;
}
export enum InstanceState {