From 11fb8e028440969334f3035b54571035d425176b Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Wed, 9 Apr 2025 11:26:49 +0200
Subject: [PATCH] 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
- bonus : disable subscription switch from yearly to monthly
closes https://github.com/twentyhq/core-team-issues/issues/681
---
.../twenty-front/src/generated/graphql.tsx | 87 +++++++++++---
.../components/SettingsBillingCoverImage.tsx | 22 ----
.../SettingsBillingLabelValueItem.tsx | 40 +++++++
.../SettingsBillingMonthlyCreditsSection.tsx | 94 +++++++++++++++
.../graphql/getMeteredProductsUsage.ts | 13 ++
.../switchSubscriptionToYearlyInterval.ts | 9 ++
.../graphql/updateBillingSubscription.ts | 9 --
.../hooks/useGetWorkflowNodeExecutionUsage.ts | 39 ++++++
.../snack-bar-manager/components/SnackBar.tsx | 4 +-
.../src/pages/onboarding/ChooseYourPlan.tsx | 4 +-
.../src/pages/settings/SettingsBilling.tsx | 113 +++++++-----------
.../core-modules/billing/billing.exception.ts | 2 +
.../core-modules/billing/billing.module.ts | 2 +
.../core-modules/billing/billing.resolver.ts | 20 +++-
.../billing-metered-product-usage.output.ts | 27 +++++
.../filters/billing-api-exception.filter.ts | 2 +
.../billing-subscription-item.service.ts | 82 +++++++++++++
.../services/billing-subscription.service.ts | 15 ++-
.../billing/services/billing-usage.service.ts | 62 ++++++++++
.../stripe-billing-meter-event.service.ts | 20 ++++
.../workspace.integration-spec.ts | 6 +-
.../progress-bar/components/ProgressBar.tsx | 36 ++++--
.../src/theme/constants/BorderCommon.ts | 1 +
23 files changed, 570 insertions(+), 139 deletions(-)
delete mode 100644 packages/twenty-front/src/modules/billing/components/SettingsBillingCoverImage.tsx
create mode 100644 packages/twenty-front/src/modules/billing/components/SettingsBillingLabelValueItem.tsx
create mode 100644 packages/twenty-front/src/modules/billing/components/SettingsBillingMonthlyCreditsSection.tsx
create mode 100644 packages/twenty-front/src/modules/billing/graphql/getMeteredProductsUsage.ts
create mode 100644 packages/twenty-front/src/modules/billing/graphql/switchSubscriptionToYearlyInterval.ts
delete mode 100644 packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts
create mode 100644 packages/twenty-front/src/modules/billing/hooks/useGetWorkflowNodeExecutionUsage.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-item.service.ts
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index 6d77b82db..878e7002b 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -145,6 +145,17 @@ export type BillingEndTrialPeriodOutput = {
status?: Maybe;
};
+export type BillingMeteredProductUsageOutput = {
+ __typename?: 'BillingMeteredProductUsageOutput';
+ includedFreeQuantity: Scalars['Float'];
+ periodEnd: Scalars['DateTime'];
+ periodStart: Scalars['DateTime'];
+ productKey: BillingProductKey;
+ totalCostCents: Scalars['Float'];
+ unitPriceCents: Scalars['Float'];
+ usageQuantity: Scalars['Float'];
+};
+
/** The different billing plans available */
export enum BillingPlanKey {
ENTERPRISE = 'ENTERPRISE',
@@ -876,8 +887,8 @@ export type Mutation = {
signUpInNewWorkspace: SignUpOutput;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
submitFormStep: Scalars['Boolean'];
+ switchToYearlyInterval: BillingUpdateOutput;
track: Analytics;
- updateBillingSubscription: BillingUpdateOutput;
updateLabPublicFeatureFlag: FeatureFlag;
updateOneField: Field;
updateOneObject: Object;
@@ -1419,6 +1430,7 @@ export type Query = {
getAvailablePackages: Scalars['JSON'];
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
+ getMeteredProductsUsage: Array;
getPostgresCredentials?: Maybe;
getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput;
getQueueMetrics: QueueMetricsData;
@@ -2566,10 +2578,15 @@ export type EndSubscriptionTrialPeriodMutationVariables = Exact<{ [key: string]:
export type EndSubscriptionTrialPeriodMutation = { __typename?: 'Mutation', endSubscriptionTrialPeriod: { __typename?: 'BillingEndTrialPeriodOutput', status?: SubscriptionStatus | null, hasPaymentMethod: boolean } };
-export type UpdateBillingSubscriptionMutationVariables = Exact<{ [key: string]: never; }>;
+export type GetMeteredProductsUsageQueryVariables = Exact<{ [key: string]: never; }>;
-export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updateBillingSubscription: { __typename?: 'BillingUpdateOutput', success: boolean } };
+export type GetMeteredProductsUsageQuery = { __typename?: 'Query', getMeteredProductsUsage: Array<{ __typename?: 'BillingMeteredProductUsageOutput', productKey: BillingProductKey, usageQuantity: number, includedFreeQuantity: number, unitPriceCents: number, totalCostCents: number }> };
+
+export type SwitchSubscriptionToYearlyIntervalMutationVariables = Exact<{ [key: string]: never; }>;
+
+
+export type SwitchSubscriptionToYearlyIntervalMutation = { __typename?: 'Mutation', switchToYearlyInterval: { __typename?: 'BillingUpdateOutput', success: boolean } };
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
@@ -4226,38 +4243,76 @@ export function useEndSubscriptionTrialPeriodMutation(baseOptions?: Apollo.Mutat
export type EndSubscriptionTrialPeriodMutationHookResult = ReturnType;
export type EndSubscriptionTrialPeriodMutationResult = Apollo.MutationResult;
export type EndSubscriptionTrialPeriodMutationOptions = Apollo.BaseMutationOptions;
-export const UpdateBillingSubscriptionDocument = gql`
- mutation UpdateBillingSubscription {
- updateBillingSubscription {
+export const GetMeteredProductsUsageDocument = gql`
+ query GetMeteredProductsUsage {
+ getMeteredProductsUsage {
+ productKey
+ usageQuantity
+ includedFreeQuantity
+ unitPriceCents
+ totalCostCents
+ }
+}
+ `;
+
+/**
+ * __useGetMeteredProductsUsageQuery__
+ *
+ * To run a query within a React component, call `useGetMeteredProductsUsageQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetMeteredProductsUsageQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetMeteredProductsUsageQuery({
+ * variables: {
+ * },
+ * });
+ */
+export function useGetMeteredProductsUsageQuery(baseOptions?: Apollo.QueryHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useQuery(GetMeteredProductsUsageDocument, options);
+ }
+export function useGetMeteredProductsUsageLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useLazyQuery(GetMeteredProductsUsageDocument, options);
+ }
+export type GetMeteredProductsUsageQueryHookResult = ReturnType;
+export type GetMeteredProductsUsageLazyQueryHookResult = ReturnType;
+export type GetMeteredProductsUsageQueryResult = Apollo.QueryResult;
+export const SwitchSubscriptionToYearlyIntervalDocument = gql`
+ mutation SwitchSubscriptionToYearlyInterval {
+ switchToYearlyInterval {
success
}
}
`;
-export type UpdateBillingSubscriptionMutationFn = Apollo.MutationFunction;
+export type SwitchSubscriptionToYearlyIntervalMutationFn = Apollo.MutationFunction;
/**
- * __useUpdateBillingSubscriptionMutation__
+ * __useSwitchSubscriptionToYearlyIntervalMutation__
*
- * To run a mutation, you first call `useUpdateBillingSubscriptionMutation` within a React component and pass it any options that fit your needs.
- * When your component renders, `useUpdateBillingSubscriptionMutation` returns a tuple that includes:
+ * To run a mutation, you first call `useSwitchSubscriptionToYearlyIntervalMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useSwitchSubscriptionToYearlyIntervalMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
- * const [updateBillingSubscriptionMutation, { data, loading, error }] = useUpdateBillingSubscriptionMutation({
+ * const [switchSubscriptionToYearlyIntervalMutation, { data, loading, error }] = useSwitchSubscriptionToYearlyIntervalMutation({
* variables: {
* },
* });
*/
-export function useUpdateBillingSubscriptionMutation(baseOptions?: Apollo.MutationHookOptions) {
+export function useSwitchSubscriptionToYearlyIntervalMutation(baseOptions?: Apollo.MutationHookOptions) {
const options = {...defaultOptions, ...baseOptions}
- return Apollo.useMutation(UpdateBillingSubscriptionDocument, options);
+ return Apollo.useMutation(SwitchSubscriptionToYearlyIntervalDocument, options);
}
-export type UpdateBillingSubscriptionMutationHookResult = ReturnType;
-export type UpdateBillingSubscriptionMutationResult = Apollo.MutationResult;
-export type UpdateBillingSubscriptionMutationOptions = Apollo.BaseMutationOptions;
+export type SwitchSubscriptionToYearlyIntervalMutationHookResult = ReturnType;
+export type SwitchSubscriptionToYearlyIntervalMutationResult = Apollo.MutationResult;
+export type SwitchSubscriptionToYearlyIntervalMutationOptions = Apollo.BaseMutationOptions;
export const GetClientConfigDocument = gql`
query GetClientConfig {
clientConfig {
diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingCoverImage.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingCoverImage.tsx
deleted file mode 100644
index b48ced04d..000000000
--- a/packages/twenty-front/src/modules/billing/components/SettingsBillingCoverImage.tsx
+++ /dev/null
@@ -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 ;
-};
diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingLabelValueItem.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingLabelValueItem.tsx
new file mode 100644
index 000000000..e6e7bd9e0
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingLabelValueItem.tsx
@@ -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 (
+
+ {label}
+
+ {value}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingMonthlyCreditsSection.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingMonthlyCreditsSection.tsx
new file mode 100644
index 000000000..5eb24ed59
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingMonthlyCreditsSection.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {!isTrialing && (
+
+ )}
+
+ {!isTrialing && (
+
+ )}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/billing/graphql/getMeteredProductsUsage.ts b/packages/twenty-front/src/modules/billing/graphql/getMeteredProductsUsage.ts
new file mode 100644
index 000000000..3732aee09
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/graphql/getMeteredProductsUsage.ts
@@ -0,0 +1,13 @@
+import { gql } from '@apollo/client';
+
+export const GET_METERED_PRODUCTS_USAGE = gql`
+ query GetMeteredProductsUsage {
+ getMeteredProductsUsage {
+ productKey
+ usageQuantity
+ includedFreeQuantity
+ unitPriceCents
+ totalCostCents
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/billing/graphql/switchSubscriptionToYearlyInterval.ts b/packages/twenty-front/src/modules/billing/graphql/switchSubscriptionToYearlyInterval.ts
new file mode 100644
index 000000000..896214674
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/graphql/switchSubscriptionToYearlyInterval.ts
@@ -0,0 +1,9 @@
+import { gql } from '@apollo/client';
+
+export const SWITCH_SUBSCRIPTION_TO_YEARLY_INTERVAL = gql`
+ mutation SwitchSubscriptionToYearlyInterval {
+ switchToYearlyInterval {
+ success
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts b/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts
deleted file mode 100644
index 2f75c7610..000000000
--- a/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { gql } from '@apollo/client';
-
-export const UPDATE_BILLING_SUBSCRIPTION = gql`
- mutation UpdateBillingSubscription {
- updateBillingSubscription {
- success
- }
- }
-`;
diff --git a/packages/twenty-front/src/modules/billing/hooks/useGetWorkflowNodeExecutionUsage.ts b/packages/twenty-front/src/modules/billing/hooks/useGetWorkflowNodeExecutionUsage.ts
new file mode 100644
index 000000000..fa304f2e9
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/hooks/useGetWorkflowNodeExecutionUsage.ts
@@ -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,
+ };
+};
diff --git a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
index 59b974778..dbd068cce 100644
--- a/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
+++ b/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
@@ -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 }}
>
diff --git a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx
index d126486f0..898c2f305 100644
--- a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx
+++ b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx
@@ -104,7 +104,7 @@ export const ChooseYourPlan = () => {
t`Email integration`,
t`Custom objects`,
t`API & Webhooks`,
- t`20 000 workflow node executions`,
+ t`20,000 workflow node executions`,
t`SSO (SAML / OIDC)`,
];
}
@@ -115,7 +115,7 @@ export const ChooseYourPlan = () => {
t`Email integration`,
t`Custom objects`,
t`API & Webhooks`,
- t`10 000 workflow node executions`,
+ t`10,000 workflow node executions`,
];
};
diff --git a/packages/twenty-front/src/pages/settings/SettingsBilling.tsx b/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
index ae49b9b1a..6444fa14a 100644
--- a/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
+++ b/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
@@ -3,7 +3,7 @@ import { useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
-import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage';
+import { SettingsBillingMonthlyCreditsSection } from '@/billing/components/SettingsBillingMonthlyCreditsSection';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath';
@@ -11,55 +11,31 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
+import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
-import {
- SubscriptionInterval,
- SubscriptionStatus,
- useBillingPortalSessionQuery,
- useUpdateBillingSubscriptionMutation,
-} from '~/generated/graphql';
-import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { isDefined } from 'twenty-shared/utils';
-import { Button } from 'twenty-ui/input';
import {
H2Title,
IconCalendarEvent,
IconCircleX,
IconCreditCard,
} from 'twenty-ui/display';
+import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
-
-type SwitchInfo = {
- newInterval: SubscriptionInterval;
- to: string;
- from: string;
- impact: string;
-};
+import {
+ FeatureFlagKey,
+ SubscriptionInterval,
+ SubscriptionStatus,
+ useBillingPortalSessionQuery,
+ useSwitchSubscriptionToYearlyIntervalMutation,
+} from '~/generated/graphql';
+import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SettingsBilling = () => {
const { t } = useLingui();
const { redirect } = useRedirect();
- const MONTHLY_SWITCH_INFO: SwitchInfo = {
- newInterval: SubscriptionInterval.Year,
- to: t`to yearly`,
- from: t`from monthly to yearly`,
- impact: t`You will be charged immediately for the full year.`,
- };
-
- const YEARLY_SWITCH_INFO: SwitchInfo = {
- newInterval: SubscriptionInterval.Month,
- to: t`to monthly`,
- from: t`from yearly to monthly`,
- impact: t`Your credit balance will be used to pay the monthly bills.`,
- };
-
- const SWITCH_INFOS = {
- year: YEARLY_SWITCH_INFO,
- month: MONTHLY_SWITCH_INFO,
- };
-
const { enqueueSnackBar } = useSnackBar();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@@ -72,14 +48,11 @@ export const SettingsBilling = () => {
subscriptionStatus !== SubscriptionStatus.Canceled;
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
- const switchingInfo =
- currentWorkspace?.currentBillingSubscription?.interval ===
- SubscriptionInterval.Year
- ? SWITCH_INFOS.year
- : SWITCH_INFOS.month;
+
const [isSwitchingIntervalModalOpen, setIsSwitchingIntervalModalOpen] =
useState(false);
- const [updateBillingSubscription] = useUpdateBillingSubscriptionMutation();
+ const [switchToYearlyInterval] =
+ useSwitchSubscriptionToYearlyIntervalMutation();
const { data, loading } = useBillingPortalSessionQuery({
variables: {
returnUrlPath: '/settings/billing',
@@ -100,33 +73,33 @@ export const SettingsBilling = () => {
setIsSwitchingIntervalModalOpen(true);
};
- const from = switchingInfo.from;
- const to = switchingInfo.to;
- const impact = switchingInfo.impact;
-
const switchInterval = async () => {
try {
- await updateBillingSubscription();
+ await switchToYearlyInterval();
if (isDefined(currentWorkspace?.currentBillingSubscription)) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription: {
...currentWorkspace?.currentBillingSubscription,
- interval: switchingInfo.newInterval,
+ interval: SubscriptionInterval.Year,
},
};
setCurrentWorkspace(newCurrentWorkspace);
}
- enqueueSnackBar(t`Subscription has been switched ${to}`, {
+ enqueueSnackBar(t`Subscription has been switched to yearly.`, {
variant: SnackBarVariant.Success,
});
} catch (error: any) {
- enqueueSnackBar(t`Error while switching subscription ${to}.`, {
+ enqueueSnackBar(t`Error while switching subscription to yearly.`, {
variant: SnackBarVariant.Error,
});
}
};
+ const isMeteredProductBillingEnabled = useIsFeatureEnabled(
+ FeatureFlagKey.IsMeteredProductBillingEnabled,
+ );
+
return (
{
]}
>
-
+ {isMeteredProductBillingEnabled && (
+
+ )}
{
disabled={billingPortalButtonDisabled}
/>
-
+ {currentWorkspace?.currentBillingSubscription?.interval ===
+ SubscriptionInterval.Month && (
+
+ )}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
index 6589b9c9d..1c4540fae 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
@@ -13,6 +13,7 @@ export enum BillingExceptionCode {
BILLING_PLAN_NOT_FOUND = 'BILLING_PLAN_NOT_FOUND',
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
+ BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND',
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND',
BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED',
@@ -20,4 +21,5 @@ export enum BillingExceptionCode {
BILLING_UNHANDLED_ERROR = 'BILLING_UNHANDLED_ERROR',
BILLING_STRIPE_ERROR = 'BILLING_STRIPE_ERROR',
BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD = 'BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD',
+ BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE',
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts
index 4ff9ae3d1..f36373bec 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts
@@ -20,6 +20,7 @@ import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
+import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
@@ -64,6 +65,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
controllers: [BillingController],
providers: [
BillingSubscriptionService,
+ BillingSubscriptionItemService,
BillingWebhookSubscriptionService,
BillingWebhookEntitlementService,
BillingPortalWorkspaceService,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts
index 5ef85c335..12fd65190 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts
@@ -8,6 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
import { BillingEndTrialPeriodOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output';
+import { BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output';
import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output';
@@ -15,10 +16,10 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
+import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
-import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
@@ -44,8 +45,8 @@ export class BillingResolver {
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService,
private readonly billingPlanService: BillingPlanService,
- private readonly featureFlagService: FeatureFlagService,
private readonly billingService: BillingService,
+ private readonly billingUsageService: BillingUsageService,
private readonly permissionsService: PermissionsService,
) {}
@@ -117,8 +118,8 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionsGuard(SettingPermissionType.WORKSPACE),
)
- async updateBillingSubscription(@AuthWorkspace() workspace: Workspace) {
- await this.billingSubscriptionService.applyBillingSubscription(workspace);
+ async switchToYearlyInterval(@AuthWorkspace() workspace: Workspace) {
+ await this.billingSubscriptionService.switchToYearlyInterval(workspace);
return { success: true };
}
@@ -142,6 +143,17 @@ export class BillingResolver {
return await this.billingSubscriptionService.endTrialPeriod(workspace);
}
+ @Query(() => [BillingMeteredProductUsageOutput])
+ @UseGuards(
+ WorkspaceAuthGuard,
+ SettingsPermissionsGuard(SettingPermissionType.WORKSPACE),
+ )
+ async getMeteredProductsUsage(
+ @AuthWorkspace() workspace: Workspace,
+ ): Promise {
+ return await this.billingUsageService.getMeteredProductsUsage(workspace);
+ }
+
private async validateCanCheckoutSessionPermissionOrThrow({
workspaceId,
userWorkspaceId,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts
new file mode 100644
index 000000000..64405120d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts
@@ -0,0 +1,27 @@
+import { Field, ObjectType } from '@nestjs/graphql';
+
+import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
+
+@ObjectType()
+export class BillingMeteredProductUsageOutput {
+ @Field(() => BillingProductKey)
+ productKey: BillingProductKey;
+
+ @Field(() => Date)
+ periodStart: Date;
+
+ @Field(() => Date)
+ periodEnd: Date;
+
+ @Field(() => Number)
+ usageQuantity: number;
+
+ @Field(() => Number)
+ includedFreeQuantity: number;
+
+ @Field(() => Number)
+ unitPriceCents: number;
+
+ @Field(() => Number)
+ totalCostCents: number;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
index df039ae75..d146efada 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
@@ -42,6 +42,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
case BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND:
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
+ case BillingExceptionCode.BILLING_METER_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,
@@ -49,6 +50,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
);
case BillingExceptionCode.BILLING_METER_EVENT_FAILED:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD:
+ case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE:
return this.httpExceptionHandlerService.handleError(
exception,
response,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-item.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-item.service.ts
new file mode 100644
index 000000000..f8b4a9d4a
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-item.service.ts
@@ -0,0 +1,82 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { JsonContains, Repository } from 'typeorm';
+
+import {
+ BillingException,
+ BillingExceptionCode,
+} from 'src/engine/core-modules/billing/billing.exception';
+import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
+import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
+import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
+
+@Injectable()
+export class BillingSubscriptionItemService {
+ constructor(
+ @InjectRepository(BillingSubscriptionItem, 'core')
+ private readonly billingSubscriptionItemRepository: Repository,
+ ) {}
+
+ async getMeteredSubscriptionItemDetails(subscriptionId: string) {
+ const meteredSubscriptionItems =
+ await this.billingSubscriptionItemRepository.find({
+ where: {
+ billingSubscriptionId: subscriptionId,
+ billingProduct: {
+ metadata: JsonContains({
+ priceUsageBased: BillingUsageType.METERED,
+ }),
+ },
+ },
+ relations: ['billingProduct', 'billingProduct.billingPrices'],
+ });
+
+ return meteredSubscriptionItems.map((item) => {
+ const price = this.findMatchingPrice(item);
+
+ const stripeMeterId = price.stripeMeterId;
+
+ if (!stripeMeterId) {
+ throw new BillingException(
+ `Stripe meter ID not found for product ${item.billingProduct.metadata.productKey}`,
+ BillingExceptionCode.BILLING_METER_NOT_FOUND,
+ );
+ }
+
+ return {
+ stripeSubscriptionItemId: item.stripeSubscriptionItemId,
+ productKey: item.billingProduct.metadata.productKey,
+ stripeMeterId,
+ includedFreeQuantity: this.getIncludedFreeQuantity(price),
+ unitPriceCents: this.getUnitPrice(price),
+ };
+ });
+ }
+
+ private findMatchingPrice(item: BillingSubscriptionItem): BillingPrice {
+ const matchingPrice = item.billingProduct.billingPrices.find(
+ (price) => price.stripePriceId === item.stripePriceId,
+ );
+
+ if (!matchingPrice) {
+ throw new BillingException(
+ `Cannot find price for product ${item.stripeProductId}`,
+ BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
+ );
+ }
+
+ return matchingPrice;
+ }
+
+ private getIncludedFreeQuantity(price: BillingPrice): number {
+ return price.tiers?.find((tier) => tier.unit_amount === 0)?.up_to || 0;
+ }
+
+ private getUnitPrice(price: BillingPrice): number {
+ return Number(
+ price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal ||
+ 0,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
index 52a92b12f..f9057e9db 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts
@@ -145,14 +145,19 @@ export class BillingSubscriptionService {
return entitlement.value;
}
- async applyBillingSubscription(workspace: Workspace) {
+ async switchToYearlyInterval(workspace: Workspace) {
const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
- const newInterval =
- billingSubscription?.interval === SubscriptionInterval.Year
- ? SubscriptionInterval.Month
- : SubscriptionInterval.Year;
+
+ if (billingSubscription.interval === SubscriptionInterval.Year) {
+ throw new BillingException(
+ 'Cannot switch from yearly to monthly billing interval',
+ BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE,
+ );
+ }
+
+ const newInterval = SubscriptionInterval.Year;
const planKey = getPlanKeyFromSubscription(billingSubscription);
const billingProductsByPlan =
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts
index c8a51ed3f..0287ee3a9 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts
@@ -3,17 +3,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
+import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
+import { BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
+import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
+import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
+import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class BillingUsageService {
@@ -24,6 +29,7 @@ export class BillingUsageService {
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
private readonly environmentService: EnvironmentService,
+ private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
) {}
async canFeatureBeUsed(workspaceId: string): Promise {
@@ -79,4 +85,60 @@ export class BillingUsageService {
);
}
}
+
+ async getMeteredProductsUsage(
+ workspace: Workspace,
+ ): Promise {
+ const subscription =
+ await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
+ { workspaceId: workspace.id },
+ );
+
+ const meteredSubscriptionItemDetails =
+ await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
+ subscription.id,
+ );
+
+ let periodStart: Date;
+ let periodEnd: Date;
+
+ if (
+ subscription.status === SubscriptionStatus.Trialing &&
+ isDefined(subscription.trialStart) &&
+ isDefined(subscription.trialEnd)
+ ) {
+ periodStart = subscription.trialStart;
+ periodEnd = subscription.trialEnd;
+ } else {
+ periodStart = subscription.currentPeriodStart;
+ periodEnd = subscription.currentPeriodEnd;
+ }
+
+ return Promise.all(
+ meteredSubscriptionItemDetails.map(async (item) => {
+ const meterEventsSum =
+ await this.stripeBillingMeterEventService.sumMeterEvents(
+ item.stripeMeterId,
+ subscription.stripeCustomerId,
+ periodStart,
+ periodEnd,
+ );
+
+ const totalCostCents =
+ meterEventsSum - item.includedFreeQuantity > 0
+ ? (meterEventsSum - item.includedFreeQuantity) * item.unitPriceCents
+ : 0;
+
+ return {
+ productKey: item.productKey,
+ periodStart,
+ periodEnd,
+ usageQuantity: meterEventsSum,
+ includedFreeQuantity: item.includedFreeQuantity,
+ unitPriceCents: item.unitPriceCents,
+ totalCostCents,
+ };
+ }),
+ );
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service.ts
index b050cd3ac..1b812b9a0 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service.ts
@@ -42,4 +42,24 @@ export class StripeBillingMeterEventService {
},
});
}
+
+ async sumMeterEvents(
+ stripeMeterId: string,
+ stripeCustomerId: string,
+ startTime: Date,
+ endTime: Date,
+ ) {
+ const eventSummaries = await this.stripe.billing.meters.listEventSummaries(
+ stripeMeterId,
+ {
+ customer: stripeCustomerId,
+ start_time: Math.floor(startTime.getTime() / (1000 * 60)) * 60,
+ end_time: Math.ceil(endTime.getTime() / (1000 * 60)) * 60,
+ },
+ );
+
+ return eventSummaries.data.reduce((acc, eventSummary) => {
+ return acc + eventSummary.aggregated_value;
+ }, 0);
+ }
}
diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts
index 2574bd914..372c07453 100644
--- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts
@@ -337,12 +337,12 @@ describe('workspace permissions', () => {
});
describe('billing', () => {
- describe('updateBillingSubscription', () => {
+ describe('switchToYearlyInterval', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
- mutation UpdateBillingSubscription {
- updateBillingSubscription {
+ mutation SwitchToYearlyInterval {
+ switchToYearlyInterval {
success
}
}
diff --git a/packages/twenty-ui/src/feedback/progress-bar/components/ProgressBar.tsx b/packages/twenty-ui/src/feedback/progress-bar/components/ProgressBar.tsx
index d30e35009..bd81d2c10 100644
--- a/packages/twenty-ui/src/feedback/progress-bar/components/ProgressBar.tsx
+++ b/packages/twenty-ui/src/feedback/progress-bar/components/ProgressBar.tsx
@@ -1,42 +1,64 @@
-import { useState } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
+import { useState } from 'react';
export type ProgressBarProps = {
- className?: string;
- color?: string;
value: number;
+ className?: string;
+ barColor?: string;
+ backgroundColor?: string;
+ withBorderRadius?: boolean;
};
export type StyledBarProps = {
className?: string;
+ backgroundColor?: string;
+ withBorderRadius?: boolean;
};
const StyledBar = styled.div`
height: ${({ theme }) => theme.spacing(2)};
+ background-color: ${({ backgroundColor }) => backgroundColor};
+ border-radius: ${({ withBorderRadius, theme }) =>
+ withBorderRadius ? theme.border.radius.xxl : '0'};
overflow: hidden;
width: 100%;
`;
-const StyledBarFilling = styled(motion.div)<{ color?: string }>`
- background-color: ${({ color, theme }) => color ?? theme.font.color.primary};
+const StyledBarFilling = styled(motion.div)<{
+ barColor?: string;
+ withBorderRadius?: boolean;
+}>`
+ background-color: ${({ barColor, theme }) =>
+ barColor ?? theme.font.color.primary};
height: 100%;
+ border-radius: ${({ withBorderRadius, theme }) =>
+ withBorderRadius ? theme.border.radius.md : '0'};
`;
-export const ProgressBar = ({ className, color, value }: ProgressBarProps) => {
+export const ProgressBar = ({
+ value,
+ className,
+ barColor,
+ backgroundColor = 'none',
+ withBorderRadius = false,
+}: ProgressBarProps) => {
const [initialValue] = useState(value);
return (
);
diff --git a/packages/twenty-ui/src/theme/constants/BorderCommon.ts b/packages/twenty-ui/src/theme/constants/BorderCommon.ts
index a68f017bc..b94124450 100644
--- a/packages/twenty-ui/src/theme/constants/BorderCommon.ts
+++ b/packages/twenty-ui/src/theme/constants/BorderCommon.ts
@@ -4,6 +4,7 @@ export const BORDER_COMMON = {
sm: '4px',
md: '8px',
xl: '20px',
+ xxl: '40px',
pill: '999px',
rounded: '100%',
},