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%', },