chore(billing) - remove feature flag (#20531)

- remove feature flag
- remove old enforce cap usage logic
This commit is contained in:
Etienne
2026-05-13 18:52:29 +02:00
committed by GitHub
parent 695a374efd
commit fcd2d586ee
69 changed files with 729 additions and 4371 deletions
@@ -37,7 +37,6 @@ import {
const STRIPE_DASHBOARD_BASE_URL = 'https://dashboard.stripe.com';
const BASE_PRODUCT_KEY = 'BASE_PRODUCT';
const METERED_PRODUCT_KEY = 'WORKFLOW_NODE_EXECUTION';
const RESOURCE_CREDIT_KEY = 'RESOURCE_CREDIT';
const EM_DASH = '\u2014';
@@ -320,8 +319,7 @@ export const SettingsAdminWorkspaceBillingContent = ({
Icon:
item.productKey === BASE_PRODUCT_KEY
? IconUsers
: item.productKey === METERED_PRODUCT_KEY ||
item.productKey === RESOURCE_CREDIT_KEY
: item.productKey === RESOURCE_CREDIT_KEY
? IconCoins
: IconBox,
label: item.productName || t`Unnamed product`,
@@ -5,10 +5,8 @@ import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { SettingsBillingCreditsSection } from '@/settings/billing/components/SettingsBillingCreditsSection';
import { SettingsBillingSubscriptionInfo } from '@/settings/billing/components/SettingsBillingSubscriptionInfo';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useQuery } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
@@ -17,7 +15,6 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
BillingPortalSessionDocument,
FeatureFlagKey,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
export const SettingsBillingContent = () => {
@@ -33,15 +30,8 @@ export const SettingsBillingContent = () => {
const subscriptionStatus = useSubscriptionStatus();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { isGetMeteredProductsUsageQueryLoaded } =
useGetWorkflowNodeExecutionUsage();
const { isGetResourceCreditUsageQueryLoaded } = useGetResourceCreditUsage();
const isUsageQueryLoaded = isV2
? isGetResourceCreditUsageQueryLoaded
: isGetMeteredProductsUsageQueryLoaded;
const { isGetResourceCreditUsageQueryLoaded: isUsageQueryLoaded } =
useGetResourceCreditUsage();
const hasNotCanceledCurrentSubscription =
isDefined(subscriptionStatus) &&
@@ -1,24 +1,20 @@
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { ResourceCreditPriceSelector } from '@/settings/billing/components/internal/ResourceCreditPriceSelector';
import { MeteredPriceSelector } from '@/settings/billing/components/internal/MeteredPriceSelector';
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useCurrentResourceCredit } from '@/settings/billing/hooks/useCurrentResourceCredit';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { DOCUMENTATION_PATHS } from 'twenty-shared/constants';
import { SettingsPath } from 'twenty-shared/types';
import { formatToShortNumber, getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath } from 'twenty-shared/utils';
import {
H2Title,
HorizontalSeparator,
@@ -30,10 +26,7 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
const StyledCreditUsageFooterActions = styled.div`
display: flex;
@@ -54,40 +47,21 @@ export const SettingsBillingCreditsSection = ({
const { isMonthlyPlan } = useCurrentBillingFlags();
const { getCurrentMeteredPricesByInterval } = useCurrentMetered();
const { getResourceCreditPricesByInterval } = useCurrentResourceCredit();
const { getIntervalLabel } = useBillingWording();
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { getWorkflowNodeExecutionUsage } = useGetWorkflowNodeExecutionUsage();
const { getResourceCreditUsage } = useGetResourceCreditUsage();
const {
usedCredits,
grantedCredits,
totalGrantedCredits,
unitPriceCents,
rolloverCredits,
} = isV2 ? getResourceCreditUsage() : getWorkflowNodeExecutionUsage();
const { usedCredits, grantedCredits, totalGrantedCredits, rolloverCredits } =
getResourceCreditUsage();
const progressBarValue = (usedCredits / totalGrantedCredits) * 100;
const intervalLabel = getIntervalLabel(isMonthlyPlan);
const extraCreditsUsed = Math.max(0, usedCredits - totalGrantedCredits);
const costPerExtraCredits = unitPriceCents / 100;
const costExtraCredits = (extraCreditsUsed * unitPriceCents) / 100;
const meteredBillingPrices = getCurrentMeteredPricesByInterval(
currentBillingSubscription.interval,
);
const resourceCreditPrices = getResourceCreditPricesByInterval(
currentBillingSubscription.interval,
);
@@ -143,27 +117,6 @@ export const SettingsBillingCreditsSection = ({
})}
/>
)}
{!isV2 && (
<>
<HorizontalSeparator
noMargin
color={theme.background.tertiary}
/>
<SettingsBillingLabelValueItem
label={t`Extra Credits Used`}
value={`${formatToShortNumber(extraCreditsUsed)}`}
/>
<SettingsBillingLabelValueItem
label={t`Cost per Extra Credits`}
value={`$${formatNumber(costPerExtraCredits, { abbreviate: true, decimals: 2 })}`}
/>
<SettingsBillingLabelValueItem
label={t`Cost`}
isValueInPrimaryColor={true}
value={`$${formatNumber(costExtraCredits, { decimals: 2 })}`}
/>
</>
)}
</>
)}
</SubscriptionInfoContainer>
@@ -192,17 +145,10 @@ export const SettingsBillingCreditsSection = ({
</StyledCreditUsageFooterActions>
</Section>
<Section>
{isV2 ? (
<ResourceCreditPriceSelector
resourceCreditPrices={resourceCreditPrices}
isTrialing={isTrialing}
/>
) : (
<MeteredPriceSelector
meteredBillingPrices={meteredBillingPrices}
isTrialing={isTrialing}
/>
)}
<ResourceCreditPriceSelector
resourceCreditPrices={resourceCreditPrices}
isTrialing={isTrialing}
/>
</Section>
</>
);
@@ -13,12 +13,10 @@ import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { PlansTags } from '@/settings/billing/components/internal/PlansTags';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
import { useCurrentResourceCredit } from '@/settings/billing/hooks/useCurrentResourceCredit';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { useHasNextBillingPhase } from '@/settings/billing/hooks/useHasNextBillingPhase';
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
import { useNextBillingSeats } from '@/settings/billing/hooks/useNextBillingSeats';
@@ -29,7 +27,6 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
@@ -55,8 +52,7 @@ import {
BillingProductKey,
CancelSwitchBillingIntervalDocument,
CancelSwitchBillingPlanDocument,
CancelSwitchMeteredPriceDocument,
FeatureFlagKey,
CancelSwitchResourceCreditPriceDocument,
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
@@ -107,18 +103,10 @@ export const SettingsBillingSubscriptionInfo = ({
const { openModal } = useModal();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { refetchMeteredProductsUsage } = useGetWorkflowNodeExecutionUsage();
const { refetchResourceCreditUsage } = useGetResourceCreditUsage();
const refetchUsage = isV2
? refetchResourceCreditUsage
: refetchMeteredProductsUsage;
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { currentMeteredBillingPrice } = useCurrentMetered();
const { currentResourceCreditBillingPrice } = useCurrentResourceCredit();
const { currentPlan, oppositPlan } = useCurrentPlan();
@@ -132,23 +120,16 @@ export const SettingsBillingSubscriptionInfo = ({
const { nextBillingPhase } = useNextBillingPhase();
const nextInterval =
splitedPhaseItemsInPrices?.nextBasePrice?.recurringInterval;
const nextMeteredBillingPrice = splitedPhaseItemsInPrices.nextMeteredPrice;
const nextResourceCreditPrice =
splitedPhaseItemsInPrices.nextResourceCreditPrice;
const subscriptionStatus = useSubscriptionStatus();
const currentInterval = isV2
? currentBillingSubscription.interval
: currentMeteredBillingPrice?.recurringInterval;
const currentInterval = currentBillingSubscription.interval;
const currentCreditsByPeriod = isV2
? (currentResourceCreditBillingPrice?.creditAmount ?? null)
: ((currentMeteredBillingPrice as { tiers?: { upTo: number }[] } | null)
?.tiers?.[0]?.upTo ?? null);
const currentCreditsByPeriod =
currentResourceCreditBillingPrice?.creditAmount ?? null;
const nextCreditsByPeriod = isV2
? (nextResourceCreditPrice?.creditAmount ?? null)
: (nextMeteredBillingPrice?.tiers?.[0]?.upTo ?? null);
const nextCreditsByPeriod = nextResourceCreditPrice?.creditAmount ?? null;
const {
getIntervalLabelAsAdjectiveCapitalize,
@@ -175,8 +156,8 @@ export const SettingsBillingSubscriptionInfo = ({
CancelSwitchBillingPlanDocument,
);
const [cancelSwitchMeteredPrice] = useMutation(
CancelSwitchMeteredPriceDocument,
const [cancelSwitchResourceCreditPrice] = useMutation(
CancelSwitchResourceCreditPriceDocument,
);
const setCurrentWorkspace = useSetAtomState(currentWorkspaceState);
@@ -237,7 +218,7 @@ export const SettingsBillingSubscriptionInfo = ({
currentBillingSubscription,
billingSubscriptions,
});
refetchUsage();
refetchResourceCreditUsage();
};
const switchInterval = async () => {
@@ -351,28 +332,26 @@ export const SettingsBillingSubscriptionInfo = ({
}
};
const cancelMeteredSwitching = async () => {
const cancelResourceCreditSwitching = async () => {
if (isAnyActionLoading || isCancellingMeteredSwitch) return;
setIsCancellingMeteredSwitch(true);
try {
const { data } = await cancelSwitchMeteredPrice();
const { data } = await cancelSwitchResourceCreditPrice();
if (
isDefined(data?.cancelSwitchMeteredPrice?.currentBillingSubscription)
isDefined(
data?.cancelSwitchResourceCreditPrice?.currentBillingSubscription,
)
) {
refreshWorkspace(data.cancelSwitchMeteredPrice);
refreshWorkspace(data.cancelSwitchResourceCreditPrice);
}
enqueueSuccessSnackBar({
message: isV2
? t`Credit pack switching has been cancelled.`
: t`Metered tier switching has been cancelled.`,
message: t`Credit pack switching has been cancelled.`,
});
} catch {
enqueueErrorSnackBar({
message: isV2
? t`Error while cancelling credit pack switching.`
: t`Error while cancelling metered tier switching.`,
message: t`Error while cancelling credit pack switching.`,
});
} finally {
setIsCancellingMeteredSwitch(false);
@@ -529,26 +508,16 @@ export const SettingsBillingSubscriptionInfo = ({
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{/*@todo: find a way to check if the metered tier match when interval change too*/}
{(isV2
? nextResourceCreditPrice &&
currentCreditsByPeriod !== nextCreditsByPeriod
: isDefined(nextInterval) &&
isDefined(nextCreditsByPeriod) &&
currentInterval === nextInterval &&
currentCreditsByPeriod !== nextCreditsByPeriod) && (
<Button
Icon={IconCircleX}
title={
isV2
? t`Cancel credit pack switching`
: t`Cancel metered tier switching`
}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_METERED_PRICE_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{nextResourceCreditPrice &&
currentCreditsByPeriod !== nextCreditsByPeriod && (
<Button
Icon={IconCircleX}
title={t`Cancel credit pack switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_METERED_PRICE_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
</StyledSwitchButtonContainer>
<ConfirmationModal
modalInstanceId={SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID}
@@ -615,17 +584,9 @@ export const SettingsBillingSubscriptionInfo = ({
/>
<ConfirmationModal
modalInstanceId={CANCEL_SWITCH_METERED_PRICE_MODAL_ID}
title={
isV2
? t`Cancel credit pack switching?`
: t`Cancel metered tier switching?`
}
subtitle={
isV2
? t`You have scheduled a credit pack change. Do you want to cancel it?`
: t`You have scheduled a metered tier change. Do you want to cancel it?`
}
onConfirmClick={cancelMeteredSwitching}
title={t`Cancel credit pack switching?`}
subtitle={t`You have scheduled a credit pack change. Do you want to cancel it?`}
onConfirmClick={cancelResourceCreditSwitching}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isCancellingMeteredSwitch}
@@ -1,210 +0,0 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
import {
type BillingPriceTiers,
type MeteredBillingPrice,
} from '@/settings/billing/types/billing-price-tiers.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { findOrThrow, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SetMeteredSubscriptionPriceDocument,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
const StyledRow = styled.div`
align-items: flex-end;
display: flex;
flex-wrap: wrap;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledSelectContainer = styled.div`
flex: 1 1;
`;
const StyledButtonContainer = styled.div`
flex: 0 0 auto;
`;
export const MeteredPriceSelector = ({
meteredBillingPrices,
isTrialing = false,
}: {
meteredBillingPrices: Array<MeteredBillingPrice>;
isTrialing?: boolean;
}) => {
const { currentMeteredBillingPrice } = useCurrentMetered();
const { formatNumber } = useNumberFormat();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const { refetchMeteredProductsUsage } = useGetWorkflowNodeExecutionUsage();
const { getIntervalLabel } = useBillingWording();
const [currentMeteredPrice, setCurrentMeteredPrice] = useState(
currentMeteredBillingPrice,
);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [setMeteredSubscriptionPrice, { loading: isUpdating }] = useMutation(
SetMeteredSubscriptionPriceDocument,
);
const { openModal } = useModal();
const [selectedPriceId, setSelectedPriceId] = useState<string | undefined>(
undefined,
);
if (!currentMeteredPrice) return null;
const toOption = (meteredBillingPrice: MeteredBillingPrice) => {
const price = formatNumber(meteredBillingPrice.tiers[0].flatAmount / 100);
const credits = formatNumber(meteredBillingPrice.tiers[0].upTo, {
abbreviate: true,
decimals: 2,
});
return {
label: t`${credits} Credits - $${price}`,
value: meteredBillingPrice.stripePriceId,
};
};
const options = [...meteredBillingPrices]
.sort((a, b) => a.tiers[0].flatAmount - b.tiers[0].flatAmount)
.map(toOption);
const selectedPrice = meteredBillingPrices.find(
({ stripePriceId }) => stripePriceId === selectedPriceId,
);
const isChanged =
isDefined(selectedPriceId) &&
selectedPriceId !== currentMeteredPrice?.stripePriceId;
const isUpgrade = () => {
if (
!isChanged ||
!isDefined(selectedPrice) ||
!isDefined(currentMeteredPrice)
)
return false;
return (
(selectedPrice.tiers as BillingPriceTiers)[0].flatAmount >
(currentMeteredPrice.tiers as BillingPriceTiers)[0].flatAmount
);
};
const handleChange = (priceId: string) => {
setSelectedPriceId(priceId);
};
const confirmModalId = 'METERED_PRICE_CHANGE_CONFIRMATION_MODAL';
const handleOpenConfirm = () => {
if (!isChanged || !selectedPrice) return;
openModal(confirmModalId);
};
const recurringInterval = getIntervalLabel(
currentMeteredPrice?.recurringInterval === SubscriptionInterval.Month,
);
const handleConfirmClick = async () => {
if (!selectedPrice) return;
try {
const { data } = await setMeteredSubscriptionPrice({
variables: { priceId: selectedPrice.stripePriceId },
});
if (
isDefined(
data?.setMeteredSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription:
data.setMeteredSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data?.setMeteredSubscriptionPrice.billingSubscriptions,
};
setCurrentWorkspace(newCurrentWorkspace);
refetchMeteredProductsUsage();
}
enqueueSuccessSnackBar({ message: t`Price updated.` });
setCurrentMeteredPrice(
findOrThrow(
meteredBillingPrices,
({ stripePriceId }) => stripePriceId === selectedPrice.stripePriceId,
),
);
setSelectedPriceId(undefined);
} catch {
enqueueErrorSnackBar({ message: t`Failed to update price.` });
}
};
return (
<>
<H2Title
title={t`Credit Plan`}
description={t`Number of new credits allocated every ${recurringInterval}`}
/>
<StyledRow>
<StyledSelectContainer>
<Select
dropdownId="settings_billing-metered-price"
options={options}
value={selectedPriceId ?? currentMeteredPrice.stripePriceId}
onChange={handleChange}
disabled={isUpdating || isTrialing}
description={
isTrialing ? t`Please start your subscription first` : undefined
}
fullWidth
/>
</StyledSelectContainer>
{isChanged && (
<StyledButtonContainer>
<Button
title={isUpgrade() ? t`Upgrade` : t`Downgrade`}
onClick={handleOpenConfirm}
variant="primary"
isLoading={isUpdating}
disabled={!isChanged}
accent={isUpgrade() ? 'blue' : 'danger'}
/>
</StyledButtonContainer>
)}
</StyledRow>
<ConfirmationModal
modalInstanceId={confirmModalId}
title={isUpgrade() ? t`Confirm upgrade` : t`Confirm downgrade`}
subtitle={t`Confirm changing your current credit plan.`}
confirmButtonText={isUpgrade() ? t`Upgrade` : t`Downgrade`}
confirmButtonAccent={isUpgrade() ? 'blue' : 'danger'}
loading={isUpdating}
onConfirmClick={handleConfirmClick}
/>
</>
);
};
@@ -17,7 +17,7 @@ import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SetMeteredSubscriptionPriceDocument,
SetResourceCreditSubscriptionPriceDocument,
SubscriptionInterval,
type BillingPriceLicensed,
} from '~/generated-metadata/graphql';
@@ -75,7 +75,7 @@ export const ResourceCreditPriceSelector = ({
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [setResourceCreditPrice, { loading: isUpdating }] = useMutation(
SetMeteredSubscriptionPriceDocument,
SetResourceCreditSubscriptionPriceDocument,
);
const options = [...resourceCreditPrices]
@@ -133,16 +133,16 @@ export const ResourceCreditPriceSelector = ({
});
if (
isDefined(
data?.setMeteredSubscriptionPrice.currentBillingSubscription,
data?.setResourceCreditSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription:
data.setMeteredSubscriptionPrice.currentBillingSubscription,
data.setResourceCreditSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data?.setMeteredSubscriptionPrice.billingSubscriptions,
data?.setResourceCreditSubscriptionPrice.billingSubscriptions,
};
setCurrentWorkspace(newCurrentWorkspace);
refetchResourceCreditUsage();
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const CANCEL_SWITCH_METERED_PRICE = gql`
mutation CancelSwitchMeteredPrice {
cancelSwitchMeteredPrice {
export const CANCEL_SWITCH_RESOURCE_CREDIT_PRICE = gql`
mutation CancelSwitchResourceCreditPrice {
cancelSwitchResourceCreditPrice {
currentBillingSubscription {
...CurrentBillingSubscriptionFragment
}
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const SET_METERED_SUBSCRIPTION_PRICE = gql`
mutation SetMeteredSubscriptionPrice($priceId: String!) {
setMeteredSubscriptionPrice(priceId: $priceId) {
export const SET_RESOURCE_CREDIT_SUBSCRIPTION_PRICE = gql`
mutation SetResourceCreditSubscriptionPrice($priceId: String!) {
setResourceCreditSubscriptionPrice(priceId: $priceId) {
currentBillingSubscription {
...CurrentBillingSubscriptionFragment
}
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const GET_METERED_PRODUCTS_USAGE = gql`
query GetMeteredProductsUsage {
getMeteredProductsUsage {
export const GET_RESOURCE_CREDIT_USAGE = gql`
query GetResourceCreditUsage {
getResourceCreditUsage {
productKey
usedCredits
grantedCredits
@@ -1,7 +0,0 @@
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
describe('useCurrentMetered', () => {
it('should be a function', () => {
expect(typeof useCurrentMetered).toBe('function');
});
});
@@ -1,7 +0,0 @@
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
describe('useGetWorkflowNodeExecutionUsage', () => {
it('should be a function', () => {
expect(typeof useGetWorkflowNodeExecutionUsage).toBe('function');
});
});
@@ -1,7 +1,6 @@
import { useFormatPrices } from '@/settings/billing/hooks/useFormatPrices';
import {
BillingPlanKey,
FeatureFlagKey,
SubscriptionInterval,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
@@ -11,10 +10,8 @@ import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useLingui } from '@lingui/react/macro';
import { beautifyExactDate } from '~/utils/date-utils';
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const useBillingWording = () => {
const { t } = useLingui();
@@ -28,12 +25,9 @@ export const useBillingWording = () => {
assertIsDefinedOrThrow(currentBillingSubscription);
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { formatPrices } = useFormatPrices();
const { currentPlan } = useCurrentPlan();
const { currentMeteredBillingPrice } = useCurrentMetered();
const subscriptionStatus = useSubscriptionStatus();
@@ -78,10 +72,7 @@ export const useBillingWording = () => {
const getCurrentIntervalLabel = () =>
getIntervalLabelAsAdjectiveCapitalize(
isV2
? currentBillingSubscription.interval === SubscriptionInterval.Month
: currentMeteredBillingPrice?.recurringInterval ===
SubscriptionInterval.Month,
currentBillingSubscription.interval === SubscriptionInterval.Month,
);
const enterprisePrice =
@@ -1,54 +0,0 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
import type { MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import {
BillingProductKey,
type SubscriptionInterval,
} from '~/generated-metadata/graphql';
export const useCurrentMetered = () => {
const { currentPlan } = useCurrentPlan();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
assertIsDefinedOrThrow(currentWorkspace);
const getCurrentMeteredPricesByInterval = (
interval?: SubscriptionInterval | null,
): Array<MeteredBillingPrice> => {
const meteredPrices = currentPlan.meteredProducts
.map(({ prices }) => prices)
.flat() as Array<MeteredBillingPrice>;
return interval
? meteredPrices.filter((p) => p.recurringInterval === interval)
: meteredPrices;
};
const items =
currentWorkspace.currentBillingSubscription?.billingSubscriptionItems;
const currentMeteredBillingSubscriptionItem =
items?.find(
(it) =>
it.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
) ?? null;
const meteredPrices = getCurrentMeteredPricesByInterval();
const currentMeteredBillingPrice = currentMeteredBillingSubscriptionItem
? ((meteredPrices.find(
(price) =>
price.stripePriceId ===
currentMeteredBillingSubscriptionItem.stripePriceId,
) ?? null) as MeteredBillingPrice | null)
: null;
return {
currentMeteredBillingSubscriptionItem,
currentMeteredBillingPrice,
getCurrentMeteredPricesByInterval,
};
};
@@ -8,8 +8,6 @@ import {
type SubscriptionInterval,
} from '~/generated-metadata/graphql';
// V2 hook — reads the RESOURCE_CREDIT subscription item and available pack prices
// from resourceCreditProducts. Counterpart of useCurrentMetered for V2 workspaces.
export const useCurrentResourceCredit = () => {
const { currentPlan } = useCurrentPlan();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
@@ -1,60 +0,0 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import type { MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
type BillingPlanKey,
BillingProductKey,
} from '~/generated-metadata/graphql';
export const useGetNextMeteredBillingPrice = (): MeteredBillingPrice | null => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { listPlans, isPlansLoaded } = usePlans();
const items =
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems;
const interval = currentWorkspace?.currentBillingSubscription?.interval;
const planKey = currentWorkspace?.currentBillingSubscription?.metadata?.[
'plan'
] as BillingPlanKey | undefined;
if (!items || !planKey || !isPlansLoaded) {
return null;
}
const plans = listPlans();
const currentPlan = plans.find((plan) => plan.planKey === planKey);
if (!currentPlan) {
return null;
}
const currentMeteredItem = items.find(
(item) =>
item.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!currentMeteredItem) {
return null;
}
const meteredPrices = currentPlan.meteredProducts.flatMap(
({ prices }) => prices,
) as Array<MeteredBillingPrice>;
const pricesForInterval = meteredPrices
.filter((price) => price.recurringInterval === interval)
.sort(
(priceA, priceB) =>
priceA.tiers[0].flatAmount - priceB.tiers[0].flatAmount,
);
const currentIndex = pricesForInterval.findIndex(
({ stripePriceId }) => stripePriceId === currentMeteredItem.stripePriceId,
);
if (currentIndex === -1 || currentIndex === pricesForInterval.length - 1) {
return null;
}
return pricesForInterval[currentIndex + 1];
};
@@ -2,20 +2,18 @@ import { useQuery } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import {
BillingProductKey,
GetMeteredProductsUsageDocument,
GetResourceCreditUsageDocument,
} from '~/generated-metadata/graphql';
// V2 hook — same query shape as useGetWorkflowNodeExecutionUsage but finds
// the RESOURCE_CREDIT product instead of WORKFLOW_NODE_EXECUTION.
export const useGetResourceCreditUsage = () => {
const { data, loading, refetch } = useQuery(GetMeteredProductsUsageDocument);
const { data, loading, refetch } = useQuery(GetResourceCreditUsageDocument);
const refetchResourceCreditUsage = () => {
refetch();
};
const isGetResourceCreditUsageQueryLoaded = () => {
return isDefined(data?.getMeteredProductsUsage) && !loading;
return isDefined(data?.getResourceCreditUsage) && !loading;
};
const getResourceCreditUsage = () => {
@@ -23,7 +21,7 @@ export const useGetResourceCreditUsage = () => {
throw new Error('getResourceCreditUsage was not loaded');
}
const usage = data.getMeteredProductsUsage.find(
const usage = data.getResourceCreditUsage.find(
(productUsage) =>
productUsage.productKey === BillingProductKey.RESOURCE_CREDIT,
);
@@ -1,41 +0,0 @@
import { useQuery } from '@apollo/client/react';
import {
BillingProductKey,
GetMeteredProductsUsageDocument,
} from '~/generated-metadata/graphql';
import { findOrThrow } from 'twenty-shared/utils';
export const useGetWorkflowNodeExecutionUsage = () => {
const { data, loading, refetch } = useQuery(GetMeteredProductsUsageDocument);
const refetchMeteredProductsUsage = () => {
refetch();
};
const isGetMeteredProductsUsageQueryLoaded = () => {
return data?.getMeteredProductsUsage && !loading;
};
const getMeteredProductUsage = () => {
if (!data) {
throw new Error('getMeteredProductUsage was not loaded');
}
return data.getMeteredProductsUsage;
};
const getWorkflowNodeExecutionUsage = () => {
return findOrThrow(
getMeteredProductUsage(),
(productUsage) =>
productUsage.productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
};
return {
refetchMeteredProductsUsage,
isGetMeteredProductsUsageQueryLoaded:
isGetMeteredProductsUsageQueryLoaded(),
getWorkflowNodeExecutionUsage,
};
};
@@ -1,11 +1,8 @@
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
import { usePriceAndBillingUsageByPriceId } from '@/settings/billing/hooks/usePriceAndBillingUsageByPriceId';
import { type MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import {
BillingUsageType,
FeatureFlagKey,
type BillingPriceLicensed,
} from '~/generated-metadata/graphql';
@@ -13,7 +10,6 @@ export const useSplitPhaseItemsInPrices = () => {
const { nextBillingPhase } = useNextBillingPhase();
const { getPriceAndBillingUsageByPriceId } =
usePriceAndBillingUsageByPriceId();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const splitedPhaseItemsInPrices = (nextBillingPhase?.items ?? []).reduce(
(acc, item) => {
@@ -21,27 +17,17 @@ export const useSplitPhaseItemsInPrices = () => {
item.price,
);
if (isV2) {
if (billingUsage === BillingUsageType.LICENSED) {
const licensedPrice = price as BillingPriceLicensed;
if (isDefined(licensedPrice.creditAmount)) {
acc.nextResourceCreditPrice = licensedPrice;
} else {
acc.nextBasePrice = licensedPrice;
}
}
} else {
if (billingUsage === BillingUsageType.LICENSED) {
acc.nextBasePrice = price;
}
if (billingUsage === BillingUsageType.METERED) {
acc.nextMeteredPrice = price as MeteredBillingPrice;
if (billingUsage === BillingUsageType.LICENSED) {
const licensedPrice = price as BillingPriceLicensed;
if (isDefined(licensedPrice.creditAmount)) {
acc.nextResourceCreditPrice = licensedPrice;
} else {
acc.nextBasePrice = licensedPrice;
}
}
return acc;
},
{} as {
nextMeteredPrice: MeteredBillingPrice | undefined;
nextBasePrice: BillingPriceLicensed | undefined;
nextResourceCreditPrice: BillingPriceLicensed | undefined;
},