Skip to content

Commit

Permalink
chore: PAYG billing (#8743)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
nunogois authored Nov 14, 2024
1 parent 54444a3 commit 395a4b6
Show file tree
Hide file tree
Showing 15 changed files with 431 additions and 272 deletions.
13 changes: 3 additions & 10 deletions frontend/src/component/admin/billing/Billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -35,9 +30,7 @@ export const Billing = () => {
show={
<PermissionGuard permissions={ADMIN}>
<>
<BillingDashboard
instanceStatus={instanceStatus!}
/>
<BillingDashboard />
<BillingHistory data={invoices} />
</>
</PermissionGuard>
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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 (
Expand All @@ -58,7 +68,9 @@ export const BillingInformation: FC<IBillingInformationProps> = ({
</StyledInfoLabel>
<StyledDivider />
<StyledInfoLabel>
<a href='mailto:[email protected]?subject=PRO plan clarifications'>
<a
href={`mailto:[email protected]?subject=${plan} plan clarifications`}
>
Get in touch with us
</a>{' '}
for any clarification
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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>
</>
);
};
Loading

0 comments on commit 395a4b6

Please sign in to comment.