1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02: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'; import useInvoices from 'hooks/api/getters/useInvoices/useInvoices';
export const Billing = () => { export const Billing = () => {
const { const { isBilling, refetchInstanceStatus, refresh, loading } =
instanceStatus, useInstanceStatus();
isBilling,
refetchInstanceStatus,
refresh,
loading,
} = useInstanceStatus();
const { invoices } = useInvoices(); const { invoices } = useInvoices();
useEffect(() => { useEffect(() => {
@ -35,9 +30,7 @@ export const Billing = () => {
show={ show={
<PermissionGuard permissions={ADMIN}> <PermissionGuard permissions={ADMIN}>
<> <>
<BillingDashboard <BillingDashboard />
instanceStatus={instanceStatus!}
/>
<BillingHistory data={invoices} /> <BillingHistory data={invoices} />
</> </>
</PermissionGuard> </PermissionGuard>

View File

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

View File

@ -1,8 +1,9 @@
import type { FC } from 'react';
import { Alert, Divider, Grid, styled, Typography } from '@mui/material'; import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
import { BillingInformationButton } from './BillingInformationButton/BillingInformationButton'; import { BillingInformationButton } from './BillingInformationButton/BillingInformationButton';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; 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 }) => ({ const StyledInfoBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(4), padding: theme.spacing(4),
@ -28,13 +29,22 @@ const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(2.5)} 0`, margin: `${theme.spacing(2.5)} 0`,
borderColor: theme.palette.divider, borderColor: theme.palette.divider,
})); }));
interface IBillingInformationProps {
instanceStatus: IInstanceStatus;
}
export const BillingInformation: FC<IBillingInformationProps> = ({ export const BillingInformation = () => {
instanceStatus, 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; const inactive = instanceStatus.state !== InstanceState.ACTIVE;
return ( return (
@ -58,7 +68,9 @@ export const BillingInformation: FC<IBillingInformationProps> = ({
</StyledInfoLabel> </StyledInfoLabel>
<StyledDivider /> <StyledDivider />
<StyledInfoLabel> <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 Get in touch with us
</a>{' '} </a>{' '}
for any clarification 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 { Alert, Grid, styled } from '@mui/material';
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { import { InstanceState, InstancePlan } from 'interfaces/instance';
type IInstanceStatus,
InstanceState,
InstancePlan,
} from 'interfaces/instance';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { trialHasExpired, isTrialInstance } from 'utils/instanceTrial'; import { trialHasExpired, isTrialInstance } from 'utils/instanceTrial';
import { GridRow } from 'component/common/GridRow/GridRow'; import { GridRow } from 'component/common/GridRow/GridRow';
import { GridCol } from 'component/common/GridCol/GridCol'; import { GridCol } from 'component/common/GridCol/GridCol';
import { Badge } from 'component/common/Badge/Badge'; import { Badge } from 'component/common/Badge/Badge';
import { GridColLink } from './GridColLink/GridColLink'; import { BillingDetails } from './BillingDetails';
import { useTrafficDataEstimation } from 'hooks/useTrafficData'; import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
import { useInstanceTrafficMetrics } from 'hooks/api/getters/useInstanceTrafficMetrics/useInstanceTrafficMetrics';
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 }) => ({ const StyledPlanBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(2.5), 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 }) => ({ const StyledPlanSpan = styled('span')(({ theme }) => ({
fontSize: '3.25rem', fontSize: '3.25rem',
lineHeight: 1, lineHeight: 1,
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontWeight: 800, fontWeight: 800,
marginRight: theme.spacing(1.5),
}));
const StyledPAYGSpan = styled('span')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
})); }));
const StyledTrialSpan = styled('span')(({ theme }) => ({ const StyledTrialSpan = styled('span')(({ theme }) => ({
marginLeft: theme.spacing(1.5),
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.bold,
})); }));
@ -61,74 +60,26 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
}, },
})); }));
const StyledCheckIcon = styled(CheckIcon)(({ theme }) => ({ export const BillingPlan = () => {
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();
const { const {
currentPeriod, uiConfig: { billing },
toChartData, } = useUiConfig();
toTrafficUsageSum, const { instanceStatus } = useInstanceStatus();
endpointsInfo,
getDayLabels,
calculateOverageCost,
} = useTrafficDataEstimation();
const eligibleUsers = users.filter((user: any) => user.email); const isPAYG = billing === 'pay-as-you-go';
const price = { if (!instanceStatus)
[InstancePlan.PRO]: 80, return (
[InstancePlan.COMPANY]: 0, <Grid item xs={12} md={7}>
[InstancePlan.TEAM]: 0, <StyledPlanBox data-loading sx={{ flex: 1, height: '400px' }} />
[InstancePlan.ENTERPRISE]: 0, </Grid>
[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,
); );
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; const inactive = instanceStatus.state !== InstanceState.ACTIVE;
if (loading) return null;
return ( return (
<Grid item xs={12} md={7}> <Grid item xs={12} md={7}>
<StyledPlanBox> <StyledPlanBox>
@ -139,7 +90,9 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
After you have sent your billing information, your After you have sent your billing information, your
instance will be upgraded - you don't have to do instance will be upgraded - you don't have to do
anything.{' '} 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 Get in touch with us
</a>{' '} </a>{' '}
for any clarification for any clarification
@ -147,10 +100,11 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
} }
/> />
<Badge color='success'>Current plan</Badge> <Badge color='success'>Current plan</Badge>
<Grid container> <Grid
<GridRow container
sx={(theme) => ({ marginBottom: theme.spacing(3) })} sx={(theme) => ({ marginBottom: theme.spacing(3) })}
> >
<GridRow>
<GridCol> <GridCol>
<StyledPlanSpan> <StyledPlanSpan>
{instanceStatus.plan} {instanceStatus.plan}
@ -185,134 +139,18 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
/> />
</GridCol> </GridCol>
</GridRow> </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> <GridRow>
<GridCol vertical> <ConditionallyRender
<Typography> condition={isPAYG}
<strong> show={
Accrued traffic charges <StyledPAYGSpan>Pay-as-You-Go</StyledPAYGSpan>
</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> </GridRow>
</Grid> </Grid>
</> <BillingDetails
} instanceStatus={instanceStatus}
isPAYG={isPAYG}
/> />
</StyledPlanBox> </StyledPlanBox>
</Grid> </Grid>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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