add metered products usage (#11452)
- add metered products usage module on settings/billing page - add new resolver + logic with meter event data fetching from Stripe <img width="590" alt="Screenshot 2025-04-08 at 16 34 07" src="https://github.com/user-attachments/assets/34327af1-3482-4d61-91a6-e2dbaeb017ab" /> <img width="570" alt="Screenshot 2025-04-08 at 16 31 58" src="https://github.com/user-attachments/assets/55aa221a-925f-48bf-88c4-f20713c79962" /> - bonus : disable subscription switch from yearly to monthly closes https://github.com/twentyhq/core-team-issues/issues/681
This commit is contained in:
@ -1,22 +0,0 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import DarkCoverImage from '@/billing/assets/cover-dark.png';
|
||||
import LightCoverImage from '@/billing/assets/cover-light.png';
|
||||
|
||||
const StyledCoverImageContainer = styled.div`
|
||||
align-items: center;
|
||||
background-image: ${({ theme }) =>
|
||||
theme.name === 'light'
|
||||
? `url('${LightCoverImage.toString()}')`
|
||||
: `url('${DarkCoverImage.toString()}')`};
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 162px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
`;
|
||||
export const SettingsBillingCoverImage = () => {
|
||||
return <StyledCoverImageContainer />;
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type SettingsBillingLabelValueItemProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
isValueInPrimaryColor?: boolean;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledLabelSpan = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
const StyledValueSpan = styled.span<{ isPrimaryColor: boolean }>`
|
||||
color: ${({ theme, isPrimaryColor }) =>
|
||||
isPrimaryColor ? theme.font.color.primary : theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
export const SettingsBillingLabelValueItem = ({
|
||||
label,
|
||||
value,
|
||||
isValueInPrimaryColor = false,
|
||||
}: SettingsBillingLabelValueItemProps) => {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLabelSpan>{label}</StyledLabelSpan>
|
||||
<StyledValueSpan isPrimaryColor={isValueInPrimaryColor}>
|
||||
{value}
|
||||
</StyledValueSpan>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,94 @@
|
||||
import { SettingsBillingLabelValueItem } from '@/billing/components/SettingsBillingLabelValueItem';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { ProgressBar } from 'twenty-ui/feedback';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { BACKGROUND_LIGHT, COLOR } from 'twenty-ui/theme';
|
||||
import { SubscriptionStatus } from '~/generated/graphql';
|
||||
import { formatAmount } from '~/utils/format/formatAmount';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
|
||||
const StyledMonthlyCreditsContainer = styled.div`
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledLineSeparator = styled.div`
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
`;
|
||||
|
||||
export const SettingsBillingMonthlyCreditsSection = () => {
|
||||
const subscriptionStatus = useSubscriptionStatus();
|
||||
|
||||
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
|
||||
|
||||
const {
|
||||
freeUsageQuantity,
|
||||
includedFreeQuantity,
|
||||
paidUsageQuantity,
|
||||
unitPriceCents,
|
||||
totalCostCents,
|
||||
} = useGetWorkflowNodeExecutionUsage();
|
||||
|
||||
const progressBarValue =
|
||||
freeUsageQuantity === includedFreeQuantity
|
||||
? 0
|
||||
: (freeUsageQuantity / includedFreeQuantity) * 100;
|
||||
|
||||
const formattedFreeUsageQuantity =
|
||||
freeUsageQuantity === includedFreeQuantity
|
||||
? formatAmount(freeUsageQuantity)
|
||||
: formatNumber(freeUsageQuantity);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Monthly Credits`}
|
||||
description={t`Track your monthly workflow credit consumption.`}
|
||||
/>
|
||||
<StyledMonthlyCreditsContainer>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Free Credits Used`}
|
||||
value={`${formattedFreeUsageQuantity}/${formatAmount(includedFreeQuantity)}`}
|
||||
/>
|
||||
<ProgressBar
|
||||
value={progressBarValue}
|
||||
barColor={COLOR.blue}
|
||||
backgroundColor={BACKGROUND_LIGHT.tertiary}
|
||||
withBorderRadius={true}
|
||||
/>
|
||||
|
||||
<StyledLineSeparator />
|
||||
{!isTrialing && (
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Extra Credits Used`}
|
||||
value={`${formatNumber(paidUsageQuantity)}`}
|
||||
/>
|
||||
)}
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Cost per 1k Extra Credits`}
|
||||
value={`$${formatNumber((unitPriceCents / 100) * 1000, 2)}`}
|
||||
/>
|
||||
{!isTrialing && (
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`Cost`}
|
||||
isValueInPrimaryColor={true}
|
||||
value={`$${formatNumber(totalCostCents / 100, 2)}`}
|
||||
/>
|
||||
)}
|
||||
</StyledMonthlyCreditsContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_METERED_PRODUCTS_USAGE = gql`
|
||||
query GetMeteredProductsUsage {
|
||||
getMeteredProductsUsage {
|
||||
productKey
|
||||
usageQuantity
|
||||
includedFreeQuantity
|
||||
unitPriceCents
|
||||
totalCostCents
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SWITCH_SUBSCRIPTION_TO_YEARLY_INTERVAL = gql`
|
||||
mutation SwitchSubscriptionToYearlyInterval {
|
||||
switchToYearlyInterval {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -1,9 +0,0 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_BILLING_SUBSCRIPTION = gql`
|
||||
mutation UpdateBillingSubscription {
|
||||
updateBillingSubscription {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,39 @@
|
||||
import {
|
||||
BillingProductKey,
|
||||
useGetMeteredProductsUsageQuery,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const useGetWorkflowNodeExecutionUsage = () => {
|
||||
const { data, loading } = useGetMeteredProductsUsageQuery();
|
||||
|
||||
const workflowUsage = data?.getMeteredProductsUsage.find(
|
||||
(productUsage) =>
|
||||
productUsage.productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
);
|
||||
|
||||
if (loading === true || !workflowUsage) {
|
||||
return {
|
||||
usageQuantity: 0,
|
||||
freeUsageQuantity: 0,
|
||||
includedFreeQuantity: 0,
|
||||
paidUsageQuantity: 0,
|
||||
unitPriceCents: 0,
|
||||
totalCostCents: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
usageQuantity: workflowUsage.usageQuantity,
|
||||
freeUsageQuantity:
|
||||
workflowUsage.usageQuantity > workflowUsage.includedFreeQuantity
|
||||
? workflowUsage.includedFreeQuantity
|
||||
: workflowUsage.usageQuantity,
|
||||
includedFreeQuantity: workflowUsage.includedFreeQuantity,
|
||||
paidUsageQuantity:
|
||||
workflowUsage.usageQuantity > workflowUsage.includedFreeQuantity
|
||||
? workflowUsage.usageQuantity - workflowUsage.includedFreeQuantity
|
||||
: 0,
|
||||
unitPriceCents: workflowUsage.unitPriceCents,
|
||||
totalCostCents: workflowUsage.totalCostCents,
|
||||
};
|
||||
};
|
||||
@ -11,9 +11,9 @@ import {
|
||||
IconSquareRoundedCheck,
|
||||
IconX,
|
||||
} from 'twenty-ui/display';
|
||||
import { ProgressBar, useProgressAnimation } from 'twenty-ui/feedback';
|
||||
import { LightButton, LightIconButton } from 'twenty-ui/input';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
|
||||
import { ProgressBar, useProgressAnimation } from 'twenty-ui/feedback';
|
||||
|
||||
export enum SnackBarVariant {
|
||||
Default = 'default',
|
||||
@ -205,7 +205,7 @@ export const SnackBar = ({
|
||||
{...{ className, id, role, variant }}
|
||||
>
|
||||
<StyledProgressBar
|
||||
color={theme.snackBar[variant].backgroundColor}
|
||||
barColor={theme.snackBar[variant].backgroundColor}
|
||||
value={progressValue}
|
||||
/>
|
||||
<StyledHeader>
|
||||
|
||||
Reference in New Issue
Block a user