Billing - Migrate from Stripe metering (#20298)

**Overall strategy**
**1. Introduce “Billing V2” behind a workspace flag**
Gate the new model with FeatureFlagKey.IS_BILLING_V2_ENABLED so existing
workspaces stay on the old behavior until they’re migrated or explicitly
on V2.

**2. Replace workflow metered SKUs with a resource-credit product**
Conceptually, billable “workflow execution” usage is not the primary
subscription line item anymore. Add a RESOURCE_CREDIT product (and keep
WORKFLOW_NODE_EXECUTION as deprecated for the transition). Usage and
limits are expressed through credit buckets (e.g. price metadata like
credit_amount), so one product can represent pooled credits instead of a
narrow workflow-only meter.

**3. Migrate subscriptions in two layers**
Schema/catalog: persist extra price metadata (instance upgrade) so the
server knows credit amounts and can match Stripe prices to the new
model.
Per workspace: the registered workspace command
upgrade:2-2:migrate-to-billing-v2 finds subscriptions that still have
WORKFLOW_NODE_EXECUTION, swaps those items to the right RESOURCE_CREDIT
prices (using existing Stripe schedule +
BillingSubscriptionUpdateService stack), then treats the workspace as V2
(flag). Workspaces without that legacy item or without a subscription
are skipped.

**4. Unify subscription lifecycle + usage on the server**

**5. Refresh the product surface in Settings**

Test : 

- [x] Subscribe v1 +  Update subscribe + Migrate
- [x]  Subscribe v2 + Update subscribe
This commit is contained in:
Etienne
2026-05-07 17:42:11 +02:00
committed by GitHub
parent ca58c7f15e
commit 9fc5be1c4c
93 changed files with 2939 additions and 514 deletions
@@ -38,6 +38,7 @@ 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';
type SettingsAdminWorkspaceBillingContentProps = {
@@ -319,7 +320,8 @@ export const SettingsAdminWorkspaceBillingContent = ({
Icon:
item.productKey === BASE_PRODUCT_KEY
? IconUsers
: item.productKey === METERED_PRODUCT_KEY
: item.productKey === METERED_PRODUCT_KEY ||
item.productKey === RESOURCE_CREDIT_KEY
? IconCoins
: IconBox,
label: item.productName || t`Unnamed product`,
@@ -4,9 +4,11 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
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';
@@ -15,9 +17,9 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
BillingPortalSessionDocument,
FeatureFlagKey,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
export const SettingsBillingContent = () => {
const { t } = useLingui();
@@ -31,8 +33,15 @@ 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 hasNotCanceledCurrentSubscription =
isDefined(subscriptionStatus) &&
@@ -69,7 +78,7 @@ export const SettingsBillingContent = () => {
{hasNotCanceledCurrentSubscription &&
currentWorkspace &&
currentWorkspace.currentBillingSubscription &&
isGetMeteredProductsUsageQueryLoaded && (
isUsageQueryLoaded && (
<SettingsBillingCreditsSection
currentBillingSubscription={
currentWorkspace.currentBillingSubscription
@@ -1,13 +1,17 @@
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';
@@ -26,7 +30,10 @@ 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 { SubscriptionStatus } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
const StyledCreditUsageFooterActions = styled.div`
display: flex;
@@ -48,12 +55,16 @@ 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,
@@ -61,7 +72,7 @@ export const SettingsBillingCreditsSection = ({
totalGrantedCredits,
unitPriceCents,
rolloverCredits,
} = getWorkflowNodeExecutionUsage();
} = isV2 ? getResourceCreditUsage() : getWorkflowNodeExecutionUsage();
const progressBarValue = (usedCredits / totalGrantedCredits) * 100;
@@ -77,6 +88,10 @@ export const SettingsBillingCreditsSection = ({
currentBillingSubscription.interval,
);
const resourceCreditPrices = getResourceCreditPricesByInterval(
currentBillingSubscription.interval,
);
return (
<>
<Section>
@@ -128,20 +143,27 @@ export const SettingsBillingCreditsSection = ({
})}
/>
)}
<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 })}`}
/>
{!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>
@@ -170,10 +192,17 @@ export const SettingsBillingCreditsSection = ({
</StyledCreditUsageFooterActions>
</Section>
<Section>
<MeteredPriceSelector
meteredBillingPrices={meteredBillingPrices}
isTrialing={isTrialing}
/>
{isV2 ? (
<ResourceCreditPriceSelector
resourceCreditPrices={resourceCreditPrices}
isTrialing={isTrialing}
/>
) : (
<MeteredPriceSelector
meteredBillingPrices={meteredBillingPrices}
isTrialing={isTrialing}
/>
)}
</Section>
</>
);
@@ -9,27 +9,31 @@ import {
currentWorkspaceState,
} from '@/auth/states/currentWorkspaceState';
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';
import { useNextPlan } from '@/settings/billing/hooks/useNextPlan';
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
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';
import { useLingui } from '@lingui/react/macro';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useMemo, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -46,16 +50,16 @@ import {
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import {
BillingPlanKey,
BillingProductKey,
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
CancelSwitchBillingIntervalDocument,
CancelSwitchBillingPlanDocument,
CancelSwitchMeteredPriceDocument,
FeatureFlagKey,
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
SwitchBillingPlanDocument,
SwitchSubscriptionIntervalDocument,
} from '~/generated-metadata/graphql';
@@ -103,11 +107,19 @@ 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();
const { isEnterprisePlan, isYearlyPlan, isMonthlyPlan, isProPlan } =
@@ -119,10 +131,25 @@ export const SettingsBillingSubscriptionInfo = ({
const { nextBillingSeats } = useNextBillingSeats();
const { nextBillingPhase } = useNextBillingPhase();
const nextInterval =
splitedPhaseItemsInPrices?.nextLicensedPrice?.recurringInterval;
const nextMeteredBillingPrice = splitedPhaseItemsInPrices.nextMereredPrice;
splitedPhaseItemsInPrices?.nextBasePrice?.recurringInterval;
const nextMeteredBillingPrice = splitedPhaseItemsInPrices.nextMeteredPrice;
const nextResourceCreditPrice =
splitedPhaseItemsInPrices.nextResourceCreditPrice;
const subscriptionStatus = useSubscriptionStatus();
const currentInterval = isV2
? currentBillingSubscription.interval
: currentMeteredBillingPrice?.recurringInterval;
const currentCreditsByPeriod = isV2
? (currentResourceCreditBillingPrice?.creditAmount ?? null)
: ((currentMeteredBillingPrice as { tiers?: { upTo: number }[] } | null)
?.tiers?.[0]?.upTo ?? null);
const nextCreditsByPeriod = isV2
? (nextResourceCreditPrice?.creditAmount ?? null)
: (nextMeteredBillingPrice?.tiers?.[0]?.upTo ?? null);
const {
getIntervalLabelAsAdjectiveCapitalize,
confirmationModalSwitchToProMessage,
@@ -210,7 +237,7 @@ export const SettingsBillingSubscriptionInfo = ({
currentBillingSubscription,
billingSubscriptions,
});
refetchMeteredProductsUsage();
refetchUsage();
};
const switchInterval = async () => {
@@ -337,11 +364,15 @@ export const SettingsBillingSubscriptionInfo = ({
}
enqueueSuccessSnackBar({
message: t`Metered tier switching has been cancelled.`,
message: isV2
? t`Credit pack switching has been cancelled.`
: t`Metered tier switching has been cancelled.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error while cancelling metered tier switching.`,
message: isV2
? t`Error while cancelling credit pack switching.`
: t`Error while cancelling metered tier switching.`,
});
} finally {
setIsCancellingMeteredSwitch(false);
@@ -375,8 +406,7 @@ export const SettingsBillingSubscriptionInfo = ({
label={t`Billing interval`}
Icon={IconCalendarEvent}
currentValue={getIntervalLabelAsAdjectiveCapitalize(
currentMeteredBillingPrice.recurringInterval ===
SubscriptionInterval.Month,
currentInterval === SubscriptionInterval.Month,
)}
nextValue={
nextInterval
@@ -407,13 +437,17 @@ export const SettingsBillingSubscriptionInfo = ({
<SubscriptionInfoRowContainer
label={t`Credits by period`}
Icon={IconCoins}
currentValue={formatNumber(currentMeteredBillingPrice.tiers[0].upTo, {
abbreviate: true,
decimals: 2,
})}
currentValue={
isDefined(currentCreditsByPeriod)
? formatNumber(currentCreditsByPeriod, {
abbreviate: true,
decimals: 2,
})
: undefined
}
nextValue={
nextMeteredBillingPrice
? formatNumber(nextMeteredBillingPrice.tiers[0].upTo, {
isDefined(nextCreditsByPeriod)
? formatNumber(nextCreditsByPeriod, {
abbreviate: true,
decimals: 2,
})
@@ -431,19 +465,17 @@ export const SettingsBillingSubscriptionInfo = ({
disabled={isEndTrialPeriodLoading || isAnyActionLoading}
/>
)}
{nextInterval &&
currentMeteredBillingPrice.recurringInterval !== nextInterval && (
<Button
Icon={IconCircleX}
title={t`Cancel interval switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_BILLING_INTERVAL_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{nextInterval && currentInterval !== nextInterval && (
<Button
Icon={IconCircleX}
title={t`Cancel interval switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_BILLING_INTERVAL_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{isMonthlyPlan &&
(!nextInterval ||
currentMeteredBillingPrice.recurringInterval === nextInterval) && (
(!nextInterval || currentInterval === nextInterval) && (
<Button
Icon={IconArrowUp}
title={t`Switch to Yearly`}
@@ -455,8 +487,7 @@ export const SettingsBillingSubscriptionInfo = ({
/>
)}
{isYearlyPlan &&
(!nextInterval ||
currentMeteredBillingPrice.recurringInterval === nextInterval) && (
(!nextInterval || currentInterval === nextInterval) && (
<Button
Icon={IconArrowUp}
title={t`Switch to Monthly`}
@@ -499,19 +530,25 @@ export const SettingsBillingSubscriptionInfo = ({
/>
)}
{/*@todo: find a way to check if the metered tier match when interval change too*/}
{nextInterval &&
nextMeteredBillingPrice &&
currentMeteredBillingPrice.recurringInterval === nextInterval &&
currentMeteredBillingPrice.tiers[0].upTo !==
nextMeteredBillingPrice.tiers[0].upTo && (
<Button
Icon={IconCircleX}
title={t`Cancel metered tier switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_METERED_PRICE_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{(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}
/>
)}
</StyledSwitchButtonContainer>
<ConfirmationModal
modalInstanceId={SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID}
@@ -578,8 +615,16 @@ export const SettingsBillingSubscriptionInfo = ({
/>
<ConfirmationModal
modalInstanceId={CANCEL_SWITCH_METERED_PRICE_MODAL_ID}
title={t`Cancel metered tier switching?`}
subtitle={t`You have scheduled a metered tier change. Do you want to cancel it?`}
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}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
@@ -1,4 +1,5 @@
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';
@@ -6,12 +7,12 @@ import {
type BillingPriceTiers,
type MeteredBillingPrice,
} from '@/settings/billing/types/billing-price-tiers.type';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
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';
@@ -19,10 +20,9 @@ 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 { useMutation } from '@apollo/client/react';
import {
SubscriptionInterval,
SetMeteredSubscriptionPriceDocument,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
const StyledRow = styled.div`
@@ -62,6 +62,19 @@ export const MeteredPriceSelector = ({
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, {
@@ -75,21 +88,10 @@ export const MeteredPriceSelector = ({
};
};
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [setMeteredSubscriptionPrice, { loading: isUpdating }] = useMutation(
SetMeteredSubscriptionPriceDocument,
);
const options = [...meteredBillingPrices]
.sort((a, b) => a.tiers[0].flatAmount - b.tiers[0].flatAmount)
.map(toOption);
const { openModal } = useModal();
const [selectedPriceId, setSelectedPriceId] = useState<string | undefined>(
undefined,
);
const selectedPrice = meteredBillingPrices.find(
({ stripePriceId }) => stripePriceId === selectedPriceId,
);
@@ -0,0 +1,209 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
import { useCurrentResourceCredit } from '@/settings/billing/hooks/useCurrentResourceCredit';
import { useGetResourceCreditUsage } from '@/settings/billing/hooks/useGetResourceCreditUsage';
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 { 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,
type BillingPriceLicensed,
} 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 ResourceCreditPriceSelector = ({
resourceCreditPrices,
isTrialing = false,
}: {
resourceCreditPrices: BillingPriceLicensed[];
isTrialing?: boolean;
}) => {
const { currentResourceCreditBillingPrice } = useCurrentResourceCredit();
const { formatNumber } = useNumberFormat();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const { refetchResourceCreditUsage } = useGetResourceCreditUsage();
const { getIntervalLabel } = useBillingWording();
const [currentResourceCreditPrice, setCurrentResourceCreditPrice] = useState(
currentResourceCreditBillingPrice,
);
const toOption = (price: BillingPriceLicensed) => {
const priceDisplay = formatNumber((price.unitAmount ?? 0) / 100);
const credits = formatNumber(price.creditAmount ?? 0, {
abbreviate: true,
decimals: 2,
});
return {
label: t`${credits} Credits - $${priceDisplay}`,
value: price.stripePriceId,
};
};
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [setResourceCreditPrice, { loading: isUpdating }] = useMutation(
SetMeteredSubscriptionPriceDocument,
);
const options = [...resourceCreditPrices]
.sort((a, b) => (a.creditAmount ?? 0) - (b.creditAmount ?? 0))
.map(toOption);
const { openModal } = useModal();
const [selectedPriceId, setSelectedPriceId] = useState<string | undefined>(
undefined,
);
const selectedPrice = resourceCreditPrices.find(
({ stripePriceId }) => stripePriceId === selectedPriceId,
);
const isChanged =
isDefined(selectedPriceId) &&
selectedPriceId !== currentResourceCreditPrice?.stripePriceId;
const isUpgrade = () => {
if (
!isChanged ||
!isDefined(selectedPrice) ||
!isDefined(currentResourceCreditPrice)
)
return false;
return (
(selectedPrice.creditAmount ?? 0) >
(currentResourceCreditPrice.creditAmount ?? 0)
);
};
const handleChange = (priceId: string) => {
setSelectedPriceId(priceId);
};
const confirmModalId = 'RESOURCE_CREDIT_PRICE_CHANGE_CONFIRMATION_MODAL';
const handleOpenConfirm = () => {
if (!isChanged || !selectedPrice) return;
openModal(confirmModalId);
};
const recurringInterval = getIntervalLabel(
currentResourceCreditPrice?.recurringInterval ===
SubscriptionInterval.Month,
);
const handleConfirmClick = async () => {
if (!selectedPrice) return;
try {
const { data } = await setResourceCreditPrice({
variables: { priceId: selectedPrice.stripePriceId },
});
if (
isDefined(
data?.setMeteredSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription:
data.setMeteredSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data?.setMeteredSubscriptionPrice.billingSubscriptions,
};
setCurrentWorkspace(newCurrentWorkspace);
refetchResourceCreditUsage();
}
enqueueSuccessSnackBar({ message: t`Resource credits updated.` });
const newPrice = resourceCreditPrices.find(
({ stripePriceId }) => stripePriceId === selectedPrice.stripePriceId,
);
if (isDefined(newPrice)) {
setCurrentResourceCreditPrice(newPrice);
}
setSelectedPriceId(undefined);
} catch {
enqueueErrorSnackBar({ message: t`Failed to update resource credits.` });
}
};
return (
<>
<H2Title
title={t`Resource credits`}
description={t`Number of new credits allocated every ${recurringInterval}`}
/>
<StyledRow>
<StyledSelectContainer>
<Select
dropdownId="settings_billing-resource-credit-price"
options={options}
value={
selectedPriceId ?? currentResourceCreditPrice?.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 resource credit allocation.`}
confirmButtonText={isUpgrade() ? t`Upgrade` : t`Downgrade`}
confirmButtonAccent={isUpgrade() ? 'blue' : 'danger'}
loading={isUpdating}
onConfirmClick={handleConfirmClick}
/>
</>
);
};
@@ -6,5 +6,6 @@ export const BILLING_PRICE_LICENSED_FRAGMENT = gql`
unitAmount
recurringInterval
priceUsageType
creditAmount
}
`;
@@ -6,7 +6,22 @@ export const LIST_PLANS = gql`
query listPlans {
listPlans {
planKey
licensedProducts {
baseProducts {
name
description
images
metadata {
productKey
planKey
priceUsageBased
}
... on BillingLicensedProduct {
prices {
...BillingPriceLicensedFragment
}
}
}
resourceCreditProducts {
name
description
images
@@ -8,10 +8,12 @@ export const useAllBillingPrices = () => {
const { listPlans } = usePlans();
const allBillingPrices = listPlans()
.map(({ licensedProducts, meteredProducts }) => {
return [...licensedProducts, ...meteredProducts].map(
({ prices }) => prices,
);
.map(({ baseProducts, resourceCreditProducts, meteredProducts }) => {
return [
...baseProducts,
...resourceCreditProducts,
...meteredProducts,
].map(({ prices }) => prices);
})
.flat(2) as Array<BillingPriceLicensed | BillingPriceMetered>;
@@ -10,7 +10,7 @@ export const useBaseProductByPlanKey = () => {
const getBaseProductByPlanKey = (planKey: BillingPlanKey) =>
findOrThrow(
getPlanByPlanKey(planKey).licensedProducts,
getPlanByPlanKey(planKey).baseProducts,
(product) =>
product.metadata.productKey === BillingProductKey.BASE_PRODUCT,
new Error('Base product not found'),
@@ -1,6 +1,7 @@
import { useFormatPrices } from '@/settings/billing/hooks/useFormatPrices';
import {
BillingPlanKey,
FeatureFlagKey,
SubscriptionInterval,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
@@ -13,6 +14,7 @@ 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();
@@ -26,6 +28,8 @@ export const useBillingWording = () => {
assertIsDefinedOrThrow(currentBillingSubscription);
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const { formatPrices } = useFormatPrices();
const { currentPlan } = useCurrentPlan();
@@ -74,8 +78,10 @@ export const useBillingWording = () => {
const getCurrentIntervalLabel = () =>
getIntervalLabelAsAdjectiveCapitalize(
currentMeteredBillingPrice.recurringInterval ===
SubscriptionInterval.Month,
isV2
? currentBillingSubscription.interval === SubscriptionInterval.Month
: currentMeteredBillingPrice?.recurringInterval ===
SubscriptionInterval.Month,
);
const enterprisePrice =
@@ -2,7 +2,7 @@ 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, findOrThrow } from 'twenty-shared/utils';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import {
BillingProductKey,
type SubscriptionInterval,
@@ -29,26 +29,22 @@ export const useCurrentMetered = () => {
const items =
currentWorkspace.currentBillingSubscription?.billingSubscriptionItems;
if (!items) throw new Error('billingSubscriptionItems is undefined');
if (items.length !== 2) {
throw new Error('billingSubscriptionItems must contain 2 items.');
}
const currentMeteredBillingSubscriptionItem = findOrThrow(
items,
(it) =>
it.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
new Error('Metered billing subscription item not found'),
);
const currentMeteredBillingSubscriptionItem =
items?.find(
(it) =>
it.billingProduct.metadata?.['productKey'] ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
) ?? null;
const meteredPrices = getCurrentMeteredPricesByInterval();
const currentMeteredBillingPrice = findOrThrow(
meteredPrices,
(price) =>
price.stripePriceId ===
currentMeteredBillingSubscriptionItem.stripePriceId,
) as MeteredBillingPrice;
const currentMeteredBillingPrice = currentMeteredBillingSubscriptionItem
? ((meteredPrices.find(
(price) =>
price.stripePriceId ===
currentMeteredBillingSubscriptionItem.stripePriceId,
) ?? null) as MeteredBillingPrice | null)
: null;
return {
currentMeteredBillingSubscriptionItem,
@@ -0,0 +1,51 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import {
BillingProductKey,
type BillingPriceLicensed,
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);
const getResourceCreditPricesByInterval = (
interval?: SubscriptionInterval | null,
): BillingPriceLicensed[] => {
const prices = currentPlan.resourceCreditProducts
.flatMap((product) => product.prices ?? [])
.filter((price): price is BillingPriceLicensed => isDefined(price));
return interval
? prices.filter((p) => p.recurringInterval === interval)
: prices;
};
const items =
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems;
const currentResourceCreditSubscriptionItem = items?.find(
(item) =>
item.billingProduct.metadata?.['productKey'] ===
BillingProductKey.RESOURCE_CREDIT,
);
const resourceCreditPrices = getResourceCreditPricesByInterval();
const currentResourceCreditBillingPrice = resourceCreditPrices.find(
(price) =>
price.stripePriceId ===
currentResourceCreditSubscriptionItem?.stripePriceId,
);
return {
currentResourceCreditSubscriptionItem,
currentResourceCreditBillingPrice,
getResourceCreditPricesByInterval,
};
};
@@ -0,0 +1,67 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { usePlans } from '@/settings/billing/hooks/usePlans';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
BillingProductKey,
type BillingPlanKey,
type BillingPriceLicensed,
} from '~/generated-metadata/graphql';
export const useGetNextResourceCreditPrice =
(): BillingPriceLicensed | 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 currentResourceCreditItem = items.find(
(item) =>
item.billingProduct.metadata?.['productKey'] ===
BillingProductKey.RESOURCE_CREDIT,
);
if (!currentResourceCreditItem) {
return null;
}
const resourceCreditPrices = currentPlan.resourceCreditProducts
.flatMap((product) => product.prices ?? [])
.filter(
(price): price is BillingPriceLicensed =>
price !== null && price !== undefined,
);
const pricesForInterval = resourceCreditPrices
.filter((price) => price.recurringInterval === interval)
.sort(
(priceA, priceB) =>
(priceA.creditAmount ?? 0) - (priceB.creditAmount ?? 0),
);
const currentIndex = pricesForInterval.findIndex(
({ stripePriceId }) =>
stripePriceId === currentResourceCreditItem.stripePriceId,
);
if (currentIndex === -1 || currentIndex === pricesForInterval.length - 1) {
return null;
}
return pricesForInterval[currentIndex + 1];
};
@@ -0,0 +1,43 @@
import { useQuery } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import {
BillingProductKey,
GetMeteredProductsUsageDocument,
} 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 refetchResourceCreditUsage = () => {
refetch();
};
const isGetResourceCreditUsageQueryLoaded = () => {
return isDefined(data?.getMeteredProductsUsage) && !loading;
};
const getResourceCreditUsage = () => {
if (!data) {
throw new Error('getResourceCreditUsage was not loaded');
}
const usage = data.getMeteredProductsUsage.find(
(productUsage) =>
productUsage.productKey === BillingProductKey.RESOURCE_CREDIT,
);
if (!isDefined(usage)) {
throw new Error('RESOURCE_CREDIT usage not found');
}
return usage;
};
return {
refetchResourceCreditUsage,
isGetResourceCreditUsageQueryLoaded: isGetResourceCreditUsageQueryLoaded(),
getResourceCreditUsage,
};
};
@@ -5,7 +5,8 @@ export const useListProducts = () => {
const listProducts = () =>
listPlans().flatMap((plan) => [
...plan.licensedProducts,
...plan.baseProducts,
...plan.resourceCreditProducts,
...plan.meteredProducts,
]);
@@ -1,16 +1,16 @@
import { findOrThrow, isDefined } from 'twenty-shared/utils';
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
import { findOrThrow, isDefined } from 'twenty-shared/utils';
export const useNextBillingSeats = () => {
const { splitedPhaseItemsInPrices } = useSplitPhaseItemsInPrices();
const { nextBillingPhase } = useNextBillingPhase();
const nextLicensedPrice = splitedPhaseItemsInPrices.nextLicensedPrice;
const nextBasePrice = splitedPhaseItemsInPrices.nextBasePrice;
const nextBillingSeats =
isDefined(nextLicensedPrice) && nextBillingPhase
isDefined(nextBasePrice) && nextBillingPhase
? findOrThrow(
nextBillingPhase?.items,
({ price }) => nextLicensedPrice.stripePriceId === price,
({ price }) => nextBasePrice.stripePriceId === price,
).quantity
: undefined;
@@ -1,14 +1,12 @@
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
import { usePlanByPriceId } from '@/settings/billing/hooks/usePlanByPriceId';
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
export const useNextPlan = () => {
const { splitedPhaseItemsInPrices } = useSplitPhaseItemsInPrices();
const { getPlanByPriceId } = usePlanByPriceId();
const nextPlan = splitedPhaseItemsInPrices.nextLicensedPrice
? getPlanByPriceId(
splitedPhaseItemsInPrices.nextLicensedPrice.stripePriceId,
)
const nextPlan = splitedPhaseItemsInPrices.nextBasePrice
? getPlanByPriceId(splitedPhaseItemsInPrices.nextBasePrice.stripePriceId)
: undefined;
return {
@@ -8,7 +8,10 @@ export const usePlanByPriceId = () => {
findOrThrow(
listPlans(),
(plan) =>
plan.licensedProducts.some((p) =>
plan.baseProducts.some((p) =>
p.prices?.some((price) => price.stripePriceId === priceId),
) ||
plan.resourceCreditProducts.some((p) =>
p.prices?.some((price) => price.stripePriceId === priceId),
) ||
plan.meteredProducts.some((p) =>
@@ -1,8 +1,11 @@
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';
@@ -10,23 +13,37 @@ export const useSplitPhaseItemsInPrices = () => {
const { nextBillingPhase } = useNextBillingPhase();
const { getPriceAndBillingUsageByPriceId } =
usePriceAndBillingUsageByPriceId();
const isV2 = useIsFeatureEnabled(FeatureFlagKey.IS_BILLING_V2_ENABLED);
const splitedPhaseItemsInPrices = (nextBillingPhase?.items ?? []).reduce(
(acc, item) => {
const { price, billingUsage } = getPriceAndBillingUsageByPriceId(
item.price,
);
if (billingUsage === BillingUsageType.LICENSED) {
acc.nextLicensedPrice = price;
}
if (billingUsage === BillingUsageType.METERED) {
acc.nextMereredPrice = price as MeteredBillingPrice;
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;
}
}
return acc;
},
{} as {
nextMereredPrice: MeteredBillingPrice | undefined;
nextLicensedPrice: BillingPriceLicensed | undefined;
nextMeteredPrice: MeteredBillingPrice | undefined;
nextBasePrice: BillingPriceLicensed | undefined;
nextResourceCreditPrice: BillingPriceLicensed | undefined;
},
);