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). 
This commit is contained in:
parent
54444a395c
commit
395a4b6be3
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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`,
|
||||
);
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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),
|
||||
|
@ -6,6 +6,7 @@ export interface IInstanceStatus {
|
||||
billingCenter?: string;
|
||||
state?: InstanceState;
|
||||
seats?: number;
|
||||
minSeats?: number;
|
||||
}
|
||||
|
||||
export enum InstanceState {
|
||||
|
Loading…
Reference in New Issue
Block a user