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:
@ -145,6 +145,17 @@ export type BillingEndTrialPeriodOutput = {
|
||||
status?: Maybe<SubscriptionStatus>;
|
||||
};
|
||||
|
||||
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<BillingMeteredProductUsageOutput>;
|
||||
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
||||
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<typeof useEndSubscriptionTrialPeriodMutation>;
|
||||
export type EndSubscriptionTrialPeriodMutationResult = Apollo.MutationResult<EndSubscriptionTrialPeriodMutation>;
|
||||
export type EndSubscriptionTrialPeriodMutationOptions = Apollo.BaseMutationOptions<EndSubscriptionTrialPeriodMutation, EndSubscriptionTrialPeriodMutationVariables>;
|
||||
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<GetMeteredProductsUsageQuery, GetMeteredProductsUsageQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetMeteredProductsUsageQuery, GetMeteredProductsUsageQueryVariables>(GetMeteredProductsUsageDocument, options);
|
||||
}
|
||||
export function useGetMeteredProductsUsageLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetMeteredProductsUsageQuery, GetMeteredProductsUsageQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetMeteredProductsUsageQuery, GetMeteredProductsUsageQueryVariables>(GetMeteredProductsUsageDocument, options);
|
||||
}
|
||||
export type GetMeteredProductsUsageQueryHookResult = ReturnType<typeof useGetMeteredProductsUsageQuery>;
|
||||
export type GetMeteredProductsUsageLazyQueryHookResult = ReturnType<typeof useGetMeteredProductsUsageLazyQuery>;
|
||||
export type GetMeteredProductsUsageQueryResult = Apollo.QueryResult<GetMeteredProductsUsageQuery, GetMeteredProductsUsageQueryVariables>;
|
||||
export const SwitchSubscriptionToYearlyIntervalDocument = gql`
|
||||
mutation SwitchSubscriptionToYearlyInterval {
|
||||
switchToYearlyInterval {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type UpdateBillingSubscriptionMutationFn = Apollo.MutationFunction<UpdateBillingSubscriptionMutation, UpdateBillingSubscriptionMutationVariables>;
|
||||
export type SwitchSubscriptionToYearlyIntervalMutationFn = Apollo.MutationFunction<SwitchSubscriptionToYearlyIntervalMutation, SwitchSubscriptionToYearlyIntervalMutationVariables>;
|
||||
|
||||
/**
|
||||
* __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<UpdateBillingSubscriptionMutation, UpdateBillingSubscriptionMutationVariables>) {
|
||||
export function useSwitchSubscriptionToYearlyIntervalMutation(baseOptions?: Apollo.MutationHookOptions<SwitchSubscriptionToYearlyIntervalMutation, SwitchSubscriptionToYearlyIntervalMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpdateBillingSubscriptionMutation, UpdateBillingSubscriptionMutationVariables>(UpdateBillingSubscriptionDocument, options);
|
||||
return Apollo.useMutation<SwitchSubscriptionToYearlyIntervalMutation, SwitchSubscriptionToYearlyIntervalMutationVariables>(SwitchSubscriptionToYearlyIntervalDocument, options);
|
||||
}
|
||||
export type UpdateBillingSubscriptionMutationHookResult = ReturnType<typeof useUpdateBillingSubscriptionMutation>;
|
||||
export type UpdateBillingSubscriptionMutationResult = Apollo.MutationResult<UpdateBillingSubscriptionMutation>;
|
||||
export type UpdateBillingSubscriptionMutationOptions = Apollo.BaseMutationOptions<UpdateBillingSubscriptionMutation, UpdateBillingSubscriptionMutationVariables>;
|
||||
export type SwitchSubscriptionToYearlyIntervalMutationHookResult = ReturnType<typeof useSwitchSubscriptionToYearlyIntervalMutation>;
|
||||
export type SwitchSubscriptionToYearlyIntervalMutationResult = Apollo.MutationResult<SwitchSubscriptionToYearlyIntervalMutation>;
|
||||
export type SwitchSubscriptionToYearlyIntervalMutationOptions = Apollo.BaseMutationOptions<SwitchSubscriptionToYearlyIntervalMutation, SwitchSubscriptionToYearlyIntervalMutationVariables>;
|
||||
export const GetClientConfigDocument = gql`
|
||||
query GetClientConfig {
|
||||
clientConfig {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`,
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@ -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 (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Billing`}
|
||||
@ -139,7 +112,9 @@ export const SettingsBilling = () => {
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SettingsBillingCoverImage />
|
||||
{isMeteredProductBillingEnabled && (
|
||||
<SettingsBillingMonthlyCreditsSection />
|
||||
)}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Manage your subscription`}
|
||||
@ -153,19 +128,22 @@ export const SettingsBilling = () => {
|
||||
disabled={billingPortalButtonDisabled}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Edit billing interval`}
|
||||
description={t`Switch ${from}`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconCalendarEvent}
|
||||
title={t`Switch ${to}`}
|
||||
variant="secondary"
|
||||
onClick={openSwitchingIntervalModal}
|
||||
disabled={!hasNotCanceledCurrentSubscription}
|
||||
/>
|
||||
</Section>
|
||||
{currentWorkspace?.currentBillingSubscription?.interval ===
|
||||
SubscriptionInterval.Month && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Edit billing interval`}
|
||||
description={t`Switch from monthly to yearly`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconCalendarEvent}
|
||||
title={t`Switch to yearly`}
|
||||
variant="secondary"
|
||||
onClick={openSwitchingIntervalModal}
|
||||
disabled={!hasNotCanceledCurrentSubscription}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Cancel your subscription`}
|
||||
@ -184,13 +162,10 @@ export const SettingsBilling = () => {
|
||||
<ConfirmationModal
|
||||
isOpen={isSwitchingIntervalModalOpen}
|
||||
setIsOpen={setIsSwitchingIntervalModalOpen}
|
||||
title={t`Switch billing ${to}`}
|
||||
subtitle={
|
||||
t`Are you sure that you want to change your billing interval?` +
|
||||
` ${impact}`
|
||||
}
|
||||
title={t`Switch billing to yearly`}
|
||||
subtitle={t`Are you sure that you want to change your billing interval? You will be charged immediately for the full year.`}
|
||||
onConfirmClick={switchInterval}
|
||||
confirmButtonText={t`Change ${to}`}
|
||||
confirmButtonText={t`Change to yearly`}
|
||||
confirmButtonAccent={'blue'}
|
||||
/>
|
||||
</SubMenuTopBarContainer>
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<BillingMeteredProductUsageOutput[]> {
|
||||
return await this.billingUsageService.getMeteredProductsUsage(workspace);
|
||||
}
|
||||
|
||||
private async validateCanCheckoutSessionPermissionOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<BillingSubscriptionItem>,
|
||||
) {}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 =
|
||||
|
||||
@ -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<boolean> {
|
||||
@ -79,4 +85,60 @@ export class BillingUsageService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getMeteredProductsUsage(
|
||||
workspace: Workspace,
|
||||
): Promise<BillingMeteredProductUsageOutput[]> {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<StyledBarProps>`
|
||||
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 (
|
||||
<StyledBar
|
||||
className={className}
|
||||
backgroundColor={backgroundColor}
|
||||
withBorderRadius={withBorderRadius}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.ceil(value)}
|
||||
>
|
||||
<StyledBarFilling
|
||||
initial={{ width: `${initialValue}%` }}
|
||||
animate={{ width: `${value}%` }}
|
||||
color={color}
|
||||
barColor={barColor}
|
||||
transition={{ ease: 'linear' }}
|
||||
withBorderRadius={withBorderRadius}
|
||||
/>
|
||||
</StyledBar>
|
||||
);
|
||||
|
||||
@ -4,6 +4,7 @@ export const BORDER_COMMON = {
|
||||
sm: '4px',
|
||||
md: '8px',
|
||||
xl: '20px',
|
||||
xxl: '40px',
|
||||
pill: '999px',
|
||||
rounded: '100%',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user