From 395a4b6be35e0bd87a14b208af8cb9d1a5ef7416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 14 Nov 2024 11:29:05 +0000 Subject: [PATCH] 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) --- .../src/component/admin/billing/Billing.tsx | 13 +- .../BillingDashboard/BillingDashboard.tsx | 14 +- .../BillingInformation/BillingInformation.tsx | 30 +- .../BillingPlan/BillingDetails.tsx | 23 ++ .../BillingPlan/BillingDetailsPAYG.tsx | 103 +++++++ .../BillingPlan/BillingDetailsPro.tsx | 193 +++++++++++++ .../BillingPlan/BillingPlan.tsx | 264 ++++-------------- .../SeatCostWarning/SeatCostWarning.tsx | 4 +- .../DemoDialogPlans/DemoDialogPlans.tsx | 17 +- .../OrderEnvironmentsDialogPricing.tsx | 6 +- .../UserSeats/UserSeats.test.tsx | 21 +- .../componentsStat/UserSeats/UserSeats.tsx | 5 +- .../useInstanceStatus/useInstanceStatus.ts | 4 +- frontend/src/hooks/useUsersPlan.ts | 5 +- frontend/src/interfaces/instance.ts | 1 + 15 files changed, 431 insertions(+), 272 deletions(-) create mode 100644 frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetails.tsx create mode 100644 frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPAYG.tsx create mode 100644 frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPro.tsx diff --git a/frontend/src/component/admin/billing/Billing.tsx b/frontend/src/component/admin/billing/Billing.tsx index 4d5674eb346d..dc9edee6b4a3 100644 --- a/frontend/src/component/admin/billing/Billing.tsx +++ b/frontend/src/component/admin/billing/Billing.tsx @@ -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={ <> - + diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx index 2c4ac755ee74..6604aaf3599b 100644 --- a/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx @@ -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 = ({ - instanceStatus, -}) => { +export const BillingDashboard = () => { return ( - - + + ); }; diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx index b3be58e1e5fa..09eb4a3f7e23 100644 --- a/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx @@ -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 = ({ - instanceStatus, -}) => { +export const BillingInformation = () => { + const { instanceStatus } = useInstanceStatus(); + const { + uiConfig: { billing }, + } = useUiConfig(); + const isPAYG = billing === 'pay-as-you-go'; + + if (!instanceStatus) + return ( + + + + ); + + const plan = `${instanceStatus.plan}${isPAYG ? ' Pay-as-You-Go' : ''}`; const inactive = instanceStatus.state !== InstanceState.ACTIVE; return ( @@ -58,7 +68,9 @@ export const BillingInformation: FC = ({ - + Get in touch with us {' '} for any clarification diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetails.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetails.tsx new file mode 100644 index 000000000000..d7d622445827 --- /dev/null +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetails.tsx @@ -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 ; + } + + if (instanceStatus.plan === InstancePlan.PRO) { + return ; + } + + return null; +}; diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPAYG.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPAYG.tsx new file mode 100644 index 000000000000..199f3b27cc6a --- /dev/null +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPAYG.tsx @@ -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 ( + <> + + ({ + marginBottom: theme.spacing(1.5), + })} + > + + + Paid members + + + {eligibleUsers.length} assigned of{' '} + {minSeats} minimum + + + + + ${BILLING_PAYG_USER_PRICE}/month per paid member + + + + ({ + fontSize: theme.fontSizes.mainHeader, + })} + > + ${usersCost.toFixed(2)} + + + + + + + + + ({ + fontWeight: theme.fontWeight.bold, + fontSize: theme.fontSizes.mainHeader, + })} + > + Total + + + + ({ + fontWeight: theme.fontWeight.bold, + fontSize: '2rem', + })} + > + ${totalCost.toFixed(2)} + + + + + + ); +}; diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPro.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPro.tsx new file mode 100644 index 000000000000..e41bbf20fd63 --- /dev/null +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingDetailsPro.tsx @@ -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 ( + <> + + ({ + marginBottom: theme.spacing(1.5), + })} + > + + + Included members + + + {freeAssigned} of {seats} assigned + + + + + You have {seats} team members included in your PRO + plan + + + + + included + + + ({ + marginBottom: theme.spacing(1.5), + })} + > + + + Paid members + + + {paidAssigned} assigned + + + + + ${BILLING_PRO_USER_PRICE}/month per paid member + + + + ({ + fontSize: theme.fontSizes.mainHeader, + })} + > + ${paidAssignedPrice.toFixed(2)} + + + + 0} + show={ + + + + Accrued traffic charges + + + view details + + + + + $5 dollar per 1 million started above + included data + + + + ({ + fontSize: theme.fontSizes.mainHeader, + })} + > + ${overageCost.toFixed(2)} + + + + } + /> + + + + + + ({ + fontWeight: theme.fontWeight.bold, + fontSize: theme.fontSizes.mainHeader, + })} + > + Total + + + + ({ + fontWeight: theme.fontWeight.bold, + fontSize: '2rem', + })} + > + ${totalCost.toFixed(2)} + + + + + + ); +}; diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx index e417cc654c94..79209cadc379 100644 --- a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx +++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx @@ -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 = { + [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 = ({ instanceStatus }) => { - const { users, loading } = useUsers(); - const expired = trialHasExpired(instanceStatus); - const { isPro } = useUiConfig(); - +export const BillingPlan = () => { const { - currentPeriod, - toChartData, - toTrafficUsageSum, - endpointsInfo, - getDayLabels, - calculateOverageCost, - } = useTrafficDataEstimation(); - - const eligibleUsers = users.filter((user: any) => user.email); + uiConfig: { billing }, + } = useUiConfig(); + const { instanceStatus } = useInstanceStatus(); - const price = { - [InstancePlan.PRO]: 80, - [InstancePlan.COMPANY]: 0, - [InstancePlan.TEAM]: 0, - [InstancePlan.ENTERPRISE]: 0, - [InstancePlan.UNKNOWN]: 0, - user: 15, - }; + const isPAYG = billing === 'pay-as-you-go'; - 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 ( + + + ); - 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 ( @@ -139,7 +90,9 @@ export const BillingPlan: FC = ({ instanceStatus }) => { After you have sent your billing information, your instance will be upgraded - you don't have to do anything.{' '} - + Get in touch with us {' '} for any clarification @@ -147,10 +100,11 @@ export const BillingPlan: FC = ({ instanceStatus }) => { } /> Current plan - - ({ marginBottom: theme.spacing(3) })} - > + ({ marginBottom: theme.spacing(3) })} + > + {instanceStatus.plan} @@ -185,134 +139,18 @@ export const BillingPlan: FC = ({ instanceStatus }) => { /> + + Pay-as-You-Go + } + /> + - - - ({ - marginBottom: theme.spacing(1.5), - })} - > - - - Included members - - - {freeAssigned} of 5 assigned - - - - - You have 5 team members included in - your PRO plan - - - - - - included - - - - ({ - marginBottom: theme.spacing(1.5), - })} - > - - - Paid members - - - {paidAssigned} assigned - - - - - $15/month per paid member - - - - ({ - fontSize: - theme.fontSizes.mainHeader, - })} - > - ${paidAssignedPrice.toFixed(2)} - - - - 0} - show={ - - - - - Accrued traffic charges - - - - view details - - - - - $5 dollar per 1 million - started above included data - - - - ({ - fontSize: - theme.fontSizes - .mainHeader, - })} - > - ${overageCost.toFixed(2)} - - - - } - /> - - - - - - ({ - fontWeight: - theme.fontWeight.bold, - fontSize: - theme.fontSizes.mainHeader, - })} - > - Total - - - - ({ - fontWeight: - theme.fontWeight.bold, - fontSize: '2rem', - })} - > - ${totalCost.toFixed(2)} - - - - - - } + diff --git a/frontend/src/component/admin/users/CreateUser/SeatCostWarning/SeatCostWarning.tsx b/frontend/src/component/admin/users/CreateUser/SeatCostWarning/SeatCostWarning.tsx index 83a2a49f2a43..0ef6df601a1b 100644 --- a/frontend/src/component/admin/users/CreateUser/SeatCostWarning/SeatCostWarning.tsx +++ b/frontend/src/component/admin/users/CreateUser/SeatCostWarning/SeatCostWarning.tsx @@ -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 = () => {

Heads up! You are exceeding your allocated free members included in your plan ({planUsers.length} of {seats}). - Creating this user will add $15/month to your + Creating this user will add{' '} + ${BILLING_PRO_USER_PRICE}/month to your invoice, starting with your next payment.

diff --git a/frontend/src/component/demo/DemoDialog/DemoDialogPlans/DemoDialogPlans.tsx b/frontend/src/component/demo/DemoDialog/DemoDialogPlans/DemoDialogPlans.tsx index dd5d4fa9e7c0..6728914ea5c3 100644 --- a/frontend/src/component/demo/DemoDialog/DemoDialogPlans/DemoDialogPlans.tsx +++ b/frontend/src/component/demo/DemoDialog/DemoDialogPlans/DemoDialogPlans.tsx @@ -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) => {
- $75 per user/month + ${BILLING_PAYG_USER_PRICE} per user/month - 5 users minimum + {BILLING_PAYG_DEFAULT_MINIMUM_SEATS} users + minimum