feat(billing): refacto billing (#14243)

… prices for metered billing

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-09-19 11:25:53 +02:00
committed by GitHub
parent 163890f6c8
commit 43e0cd5d05
351 changed files with 16091 additions and 6101 deletions
@@ -0,0 +1,109 @@
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsBillingCreditsSection } from '@/billing/components/SettingsBillingCreditsSection';
import { SettingsBillingSubscriptionInfo } from '@/billing/components/SettingsBillingSubscriptionInfo';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { isDefined } from 'twenty-shared/utils';
import { H2Title, IconCircleX, IconCreditCard } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
SubscriptionStatus,
useBillingPortalSessionQuery,
} from '~/generated-metadata/graphql';
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
export const SettingsBillingContent = () => {
const { t } = useLingui();
const { redirect } = useRedirect();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const subscriptions = currentWorkspace?.billingSubscriptions;
const hasSubscriptions = (subscriptions?.length ?? 0) > 0;
const subscriptionStatus = useSubscriptionStatus();
const { isGetMeteredProductsUsageQueryLoaded } =
useGetWorkflowNodeExecutionUsage();
const hasNotCanceledCurrentSubscription =
isDefined(subscriptionStatus) &&
subscriptionStatus !== SubscriptionStatus.Canceled;
const { data, loading } = useBillingPortalSessionQuery({
variables: {
returnUrlPath: '/settings/billing',
},
skip: !hasSubscriptions,
});
const billingPortalButtonDisabled =
loading || !isDefined(data) || !isDefined(data.billingPortalSession.url);
const openBillingPortal = () => {
if (isDefined(data) && isDefined(data.billingPortalSession.url)) {
redirect(data.billingPortalSession.url);
}
};
return (
<SettingsPageContainer>
{hasNotCanceledCurrentSubscription &&
currentWorkspace &&
currentWorkspace.currentBillingSubscription && (
<SettingsBillingSubscriptionInfo
currentWorkspace={currentWorkspace}
currentBillingSubscription={
currentWorkspace.currentBillingSubscription
}
/>
)}
{hasNotCanceledCurrentSubscription &&
currentWorkspace &&
currentWorkspace.currentBillingSubscription &&
isGetMeteredProductsUsageQueryLoaded && (
<SettingsBillingCreditsSection
currentBillingSubscription={
currentWorkspace.currentBillingSubscription
}
/>
)}
<Section>
<H2Title
title={t`Manage billing information`}
description={t`Edit payment method, see your invoices and more`}
/>
<Button
Icon={IconCreditCard}
title={t`View billing details`}
variant="secondary"
onClick={openBillingPortal}
disabled={billingPortalButtonDisabled}
/>
</Section>
{hasNotCanceledCurrentSubscription && (
<Section>
<H2Title
title={t`Cancel your subscription`}
description={t`Your workspace will be disabled`}
/>
<Button
Icon={IconCircleX}
title={t`Cancel Plan`}
variant="secondary"
accent="danger"
onClick={openBillingPortal}
disabled={billingPortalButtonDisabled}
/>
</Section>
)}
</SettingsPageContainer>
);
};
@@ -1,6 +1,12 @@
import { SettingsBillingLabelValueItem } from '@/billing/components/SettingsBillingLabelValueItem';
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { MeteredPriceSelector } from '@/billing/components/internal/MeteredPriceSelector';
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
import { useBillingWording } from '@/billing/hooks/useBillingWording';
import { useCurrentBillingFlags } from '@/billing/hooks/useCurrentBillingFlags';
import { useCurrentMetered } from '@/billing/hooks/useCurrentMetered';
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@@ -10,14 +16,6 @@ import { Section } from 'twenty-ui/layout';
import { BACKGROUND_LIGHT, COLOR } from 'twenty-ui/theme';
import { SubscriptionStatus } from '~/generated/graphql';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatNumber } from '~/utils/format/formatNumber';
// import { useListAvailableMeteredBillingPricesQuery } from '~/generated-metadata/graphql';
// import { MeteredPriceSelector } from '@/billing/components/internal/MeteredPriceSelector';
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import {
getIntervalLabel,
isMonthlyPlan,
} from '@/billing/utils/subscriptionFlags';
const StyledLineSeparator = styled.div`
width: 100%;
@@ -26,23 +24,41 @@ const StyledLineSeparator = styled.div`
`;
export const SettingsBillingCreditsSection = ({
currentWorkspace,
currentBillingSubscription,
}: {
currentWorkspace: CurrentWorkspace;
currentBillingSubscription: NonNullable<
CurrentWorkspace['currentBillingSubscription']
>;
}) => {
const subscriptionStatus = useSubscriptionStatus();
const { formatNumber } = useNumberFormat();
const { isMonthlyPlan } = useCurrentBillingFlags();
const { getCurrentMeteredPricesByInterval } = useCurrentMetered();
const { getIntervalLabel } = useBillingWording();
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const { usedCredits, grantedCredits, unitPriceCents } =
useGetWorkflowNodeExecutionUsage();
const { getWorkflowNodeExecutionUsage } = useGetWorkflowNodeExecutionUsage();
// const { data: meteredBillingPrices } =
// useListAvailableMeteredBillingPricesQuery();
const { usedCredits, grantedCredits, unitPriceCents } =
getWorkflowNodeExecutionUsage();
const progressBarValue = (usedCredits / grantedCredits) * 100;
const intervalLabel = getIntervalLabel(isMonthlyPlan(currentWorkspace));
const intervalLabel = getIntervalLabel(isMonthlyPlan);
const extraCreditsUsed = Math.max(0, usedCredits - grantedCredits);
const costPer1kExtraCredits = (unitPriceCents / 100) * 1000;
const costExtraCredits = (extraCreditsUsed * unitPriceCents) / 100;
const meteredBillingPrices = getCurrentMeteredPricesByInterval(
currentBillingSubscription.interval,
);
return (
<>
@@ -54,7 +70,7 @@ export const SettingsBillingCreditsSection = ({
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`Credits Used`}
value={`${formatNumber(usedCredits)}/${formatToShortNumber(grantedCredits)}`}
value={`${formatNumber(usedCredits)}/${formatNumber(grantedCredits, { abbreviate: true, decimals: 2 })}`}
/>
<ProgressBar
value={progressBarValue}
@@ -67,36 +83,30 @@ export const SettingsBillingCreditsSection = ({
{!isTrialing && (
<SettingsBillingLabelValueItem
label={t`Extra Credits Used`}
value={`${formatNumber(Math.max(0, usedCredits - grantedCredits))}`}
value={`${formatToShortNumber(extraCreditsUsed)}`}
/>
)}
{!isTrialing && (
<SettingsBillingLabelValueItem
label={t`Cost per 1k Extra Credits`}
value={`$${formatNumber(costPer1kExtraCredits, { abbreviate: true, decimals: 6 })}`}
/>
)}
<SettingsBillingLabelValueItem
label={t`Cost per 1k Extra Credits`}
value={`$${formatNumber((unitPriceCents / 100) * 1000, 2)}`}
/>
{!isTrialing && (
<SettingsBillingLabelValueItem
label={t`Cost`}
isValueInPrimaryColor={true}
value={`$${formatNumber(((usedCredits - grantedCredits) * unitPriceCents) / 100, 2)}`}
value={`$${formatNumber(costExtraCredits, { decimals: 2 })}`}
/>
)}
</SubscriptionInfoContainer>
</Section>
{/*<Section>*/}
{/* {meteredBillingPrices?.listAvailableMeteredBillingPrices && (*/}
{/* <MeteredPriceSelector*/}
{/* billingSubscriptionItems={*/}
{/* currentWorkspace.currentBillingSubscription*/}
{/* ?.billingSubscriptionItems ?? []*/}
{/* }*/}
{/* meteredBillingPrices={*/}
{/* meteredBillingPrices.listAvailableMeteredBillingPrices*/}
{/* }*/}
{/* isTrialing={isTrialing}*/}
{/* />*/}
{/* )}*/}
{/*</Section>*/}
<Section>
<MeteredPriceSelector
meteredBillingPrices={meteredBillingPrices}
isTrialing={isTrialing}
/>
</Section>
</>
);
};
@@ -1,16 +1,27 @@
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
import { SubscriptionInfoRowContainer } from '@/billing/components/SubscriptionInfoRowContainer';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
import { formatMonthlyPrices } from '@/billing/utils/formatMonthlyPrices';
import {
getIntervalLabel,
isEnterprisePlan as isEnterprisePlanFn,
isMonthlyPlan as isMonthlyPlanFn,
isProPlan as isProPlanFn,
isYearlyPlan as isYearlyPlanFn,
} from '@/billing/utils/subscriptionFlags';
SubscriptionInfoHeaderRow,
SubscriptionInfoRowContainer,
} from '@/billing/components/internal/SubscriptionInfoRowContainer';
import {
type CurrentWorkspace,
currentWorkspaceState,
} from '@/auth/states/currentWorkspaceState';
import { PlansTags } from '@/billing/components/internal/PlansTags';
import { useBillingWording } from '@/billing/hooks/useBillingWording';
import { useCurrentBillingFlags } from '@/billing/hooks/useCurrentBillingFlags';
import { useCurrentMetered } from '@/billing/hooks/useCurrentMetered';
import { useCurrentPlan } from '@/billing/hooks/useCurrentPlan';
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { useHasNextBillingPhase } from '@/billing/hooks/useHasNextBillingPhase';
import { useNextBillingPhase } from '@/billing/hooks/useNextBillingPhase';
import { useNextBillingSeats } from '@/billing/hooks/useNextBillingSeats';
import { useNextPlan } from '@/billing/hooks/useNextPlan';
import { useSplitPhaseItemsInPrices } from '@/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';
@@ -18,15 +29,16 @@ import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import { useMemo, useState } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconArrowUp,
IconCalendarEvent,
IconCalendarRepeat,
IconCircleX,
IconCoins,
IconTag,
IconUsers,
} from 'twenty-ui/display';
@@ -34,23 +46,39 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
BillingPlanKey,
type BillingPlanOutput,
BillingProductKey,
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
useBillingBaseProductPricesQuery,
useSwitchSubscriptionToEnterprisePlanMutation,
useSwitchSubscriptionToYearlyIntervalMutation,
useCancelSwitchBillingIntervalMutation,
useCancelSwitchBillingPlanMutation,
useCancelSwitchMeteredPriceMutation,
useSwitchBillingPlanMutation,
useSwitchSubscriptionIntervalMutation,
} from '~/generated-metadata/graphql';
import { SubscriptionStatus } from '~/generated/graphql';
import { beautifyExactDate } from '~/utils/date-utils';
const SWITCH_BILLING_INTERVAL_MODAL_ID = 'switch-billing-interval-modal';
const SWITCH_BILLING_INTERVAL_TO_MONTHLY_MODAL_ID =
'switch-billing-interval-to-monthly-modal';
const SWITCH_BILLING_PLAN_MODAL_ID = 'switch-billing-plan-modal';
const SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID =
'switch-billing-interval-to-yearly-modal';
const SWITCH_BILLING_PLAN_TO_ENTERPRISE_MODAL_ID =
'switch-billing-plan-to-enterprise-modal';
const SWITCH_BILLING_PLAN_TO_PRO_MODAL_ID = 'switch-billing-plan-to-pro-modal';
const END_TRIAL_PERIOD_MODAL_ID = 'end-trial-period-modal';
const CANCEL_SWITCH_BILLING_PLAN_MODAL_ID = 'cancel-switch-billing-plan-modal';
const CANCEL_SWITCH_BILLING_INTERVAL_MODAL_ID =
'cancel-switch-billing-interval-modal';
const CANCEL_SWITCH_METERED_PRICE_MODAL_ID =
'cancel-switch-metered-price-modal';
const StyledSwitchButtonContainer = styled.div`
align-items: center;
display: flex;
@@ -58,34 +86,64 @@ const StyledSwitchButtonContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsBillingSubscriptionInfo = () => {
export const SettingsBillingSubscriptionInfo = ({
currentWorkspace,
currentBillingSubscription,
}: {
currentWorkspace: CurrentWorkspace;
currentBillingSubscription: NonNullable<
CurrentWorkspace['currentBillingSubscription']
>;
}) => {
const { t } = useLingui();
const { formatNumber } = useNumberFormat();
const { openModal } = useModal();
const { refetchMeteredProductsUsage } = useGetWorkflowNodeExecutionUsage();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { currentMeteredBillingPrice } = useCurrentMetered();
const { currentPlan, oppositPlan } = useCurrentPlan();
const { isEnterprisePlan, isYearlyPlan, isMonthlyPlan, isProPlan } =
useCurrentBillingFlags();
const { splitedPhaseItemsInPrices } = useSplitPhaseItemsInPrices();
const { hasNextBillingPhase } = useHasNextBillingPhase();
const { nextPlan } = useNextPlan();
const { nextBillingSeats } = useNextBillingSeats();
const { nextBillingPhase } = useNextBillingPhase();
const nextInterval =
splitedPhaseItemsInPrices?.nextLicensedPrice?.recurringInterval;
const nextMeteredBillingPrice = splitedPhaseItemsInPrices.nextMereredPrice;
const subscriptionStatus = useSubscriptionStatus();
const { data: pricesData } = useBillingBaseProductPricesQuery();
const {
getIntervalLabelAsAdjectiveCapitalize,
confirmationModalSwitchToProMessage,
confirmationModalSwitchToOrganizationMessage,
confirmationModalSwitchToMonthlyMessage,
confirmationModalSwitchToYearlyMessage,
confirmationModalCancelPlanSwitchingMessage,
confirmationModalCancelIntervalSwitchingMessage,
getBeautifiedRenewDate,
} = useBillingWording();
const [switchToYearlyInterval] =
useSwitchSubscriptionToYearlyIntervalMutation();
const [switchSubscriptionIntervalMutation] =
useSwitchSubscriptionIntervalMutation();
const [switchToEnterprisePlan] =
useSwitchSubscriptionToEnterprisePlanMutation();
const [switchBillingPlan] = useSwitchBillingPlanMutation();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const [cancelSwitchBillingInterval] =
useCancelSwitchBillingIntervalMutation();
const isMonthlyPlan = isMonthlyPlanFn(currentWorkspace);
const [cancelSwitchBillingPlan] = useCancelSwitchBillingPlanMutation();
const isYearlyPlan = isYearlyPlanFn(currentWorkspace);
const [cancelSwitchMeteredPrice] = useCancelSwitchMeteredPriceMutation();
const isProPlan = isProPlanFn(currentWorkspace);
const isEnterprisePlan = isEnterprisePlanFn(currentWorkspace);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const isTrialPeriod = subscriptionStatus === SubscriptionStatus.Trialing;
@@ -95,97 +153,189 @@ export const SettingsBillingSubscriptionInfo = () => {
const { endTrialPeriod, isLoading: isEndTrialPeriodLoading } =
useEndSubscriptionTrialPeriod();
const planDescriptor = isProPlan
? { color: 'sky' as const, label: t`Pro` }
: isEnterprisePlan
? { color: 'purple' as const, label: t`Organization` }
: undefined;
const planTag = planDescriptor ? (
<>
<Tag color={planDescriptor.color} text={planDescriptor.label} />
{isTrialPeriod && <Tag color="blue" text={t`Trial`} />}
</>
) : undefined;
const intervalLabel = capitalize(getIntervalLabel(isMonthlyPlan, true));
const { [PermissionFlagType.WORKSPACE]: hasPermissionToEndTrialPeriod } =
usePermissionFlagMap();
const seats =
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems?.find(
(item) =>
item.billingProduct?.metadata.productKey ===
BillingProductKey.BASE_PRODUCT,
)?.quantity as number | undefined;
const seats = currentBillingSubscription.billingSubscriptionItems?.find(
(item) =>
item.billingProduct.metadata.productKey ===
BillingProductKey.BASE_PRODUCT,
)?.quantity as number | undefined;
const baseProductPrices = pricesData?.plans as BillingPlanOutput[];
// Loading states to avoid race conditions on actions
const [isSwitchingInterval, setIsSwitchingInterval] = useState(false);
const [isSwitchingPlan, setIsSwitchingPlan] = useState(false);
const [isCancellingPlanSwitch, setIsCancellingPlanSwitch] = useState(false);
const [isCancellingIntervalSwitch, setIsCancellingIntervalSwitch] =
useState(false);
const [isCancellingMeteredSwitch, setIsCancellingMeteredSwitch] =
useState(false);
const formattedPrices = formatMonthlyPrices(baseProductPrices);
const isAnyActionLoading = useMemo(
() =>
isSwitchingInterval ||
isSwitchingPlan ||
isCancellingPlanSwitch ||
isCancellingIntervalSwitch ||
isCancellingMeteredSwitch ||
isEndTrialPeriodLoading,
[
isSwitchingInterval,
isSwitchingPlan,
isCancellingPlanSwitch,
isCancellingIntervalSwitch,
isCancellingMeteredSwitch,
isEndTrialPeriodLoading,
],
);
const renewDate =
currentWorkspace?.currentBillingSubscription?.currentPeriodEnd;
const yearlyPrice =
formattedPrices?.[
currentWorkspace?.currentBillingSubscription?.metadata[
'plan'
] as BillingPlanKey
]?.[SubscriptionInterval.Year];
const enterprisePrice =
formattedPrices?.[BillingPlanKey.ENTERPRISE]?.[
currentWorkspace?.currentBillingSubscription?.interval as
| SubscriptionInterval.Month
| SubscriptionInterval.Year
];
const refreshWorkspace = ({
currentBillingSubscription,
billingSubscriptions,
}: Pick<
CurrentWorkspace,
'currentBillingSubscription' | 'billingSubscriptions'
>) => {
setCurrentWorkspace({
...currentWorkspace,
currentBillingSubscription,
billingSubscriptions,
});
refetchMeteredProductsUsage();
};
const switchInterval = async () => {
if (isAnyActionLoading || isSwitchingInterval) return;
setIsSwitchingInterval(true);
try {
await switchToYearlyInterval();
if (isDefined(currentWorkspace?.currentBillingSubscription)) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription: {
...currentWorkspace?.currentBillingSubscription,
interval: SubscriptionInterval.Year,
},
};
setCurrentWorkspace(newCurrentWorkspace);
const { success } = await endTrialPeriodIfNeeded();
if (success === false) {
return;
}
enqueueSuccessSnackBar({
message: t`Subscription has been switched to Yearly.`,
});
const { data } = await switchSubscriptionIntervalMutation();
const beautifiedRenewDate = getBeautifiedRenewDate();
const isCurrentMonth =
currentBillingSubscription.interval === SubscriptionInterval.Month;
const message = isCurrentMonth
? t`Subscription has been switched to Yearly.`
: t`Subscription will be switch to Monthly the ${beautifiedRenewDate}.`;
if (
isDefined(data?.switchSubscriptionInterval.currentBillingSubscription)
) {
refreshWorkspace(data.switchSubscriptionInterval);
}
enqueueSuccessSnackBar({ message });
} catch {
enqueueErrorSnackBar({
message: t`Error while switching subscription to Yearly.`,
message: t`Error while switching subscription.`,
});
} finally {
setIsSwitchingInterval(false);
}
};
const endTrialPeriodIfNeeded = async () => {
if (currentBillingSubscription.status === SubscriptionStatus.Trialing) {
return await endTrialPeriod();
}
return { success: true };
};
const switchPlan = async () => {
if (isAnyActionLoading || isSwitchingPlan) return;
setIsSwitchingPlan(true);
try {
await switchToEnterprisePlan();
if (isDefined(currentWorkspace?.currentBillingSubscription)) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription: {
...currentWorkspace?.currentBillingSubscription,
metadata: {
...currentWorkspace?.currentBillingSubscription.metadata,
plan: BillingPlanKey.ENTERPRISE,
},
},
};
setCurrentWorkspace(newCurrentWorkspace);
const { success } = await endTrialPeriodIfNeeded();
if (success === false) {
return;
}
const { data } = await switchBillingPlan();
if (isDefined(data?.switchBillingPlan.currentBillingSubscription)) {
refreshWorkspace(data.switchBillingPlan);
}
const beautifiedRenewDate = getBeautifiedRenewDate();
enqueueSuccessSnackBar({
message: t`Subscription has been switched to Organization Plan.`,
message:
oppositPlan === BillingPlanKey.ENTERPRISE
? t`Subscription has been switched to ${oppositPlan} Plan.`
: `Subscription will be switched to ${oppositPlan} Plan the ${beautifiedRenewDate}.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error while switching subscription to Organization Plan.`,
message: t`Error while switching subscription to ${oppositPlan} Plan.`,
});
} finally {
setIsSwitchingPlan(false);
}
};
const cancelPlanSwitching = async () => {
if (isAnyActionLoading || isCancellingPlanSwitch) return;
setIsCancellingPlanSwitch(true);
try {
const { data } = await cancelSwitchBillingPlan();
if (isDefined(data?.cancelSwitchBillingPlan.currentBillingSubscription)) {
refreshWorkspace(data.cancelSwitchBillingPlan);
}
enqueueSuccessSnackBar({
message: t`Plan switching has been cancelled.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error while cancelling plan switching.`,
});
} finally {
setIsCancellingPlanSwitch(false);
}
};
const cancelIntervalSwitching = async () => {
if (isAnyActionLoading || isCancellingIntervalSwitch) return;
setIsCancellingIntervalSwitch(true);
try {
const { data } = await cancelSwitchBillingInterval();
if (
isDefined(data?.cancelSwitchBillingInterval.currentBillingSubscription)
) {
refreshWorkspace(data.cancelSwitchBillingInterval);
}
enqueueSuccessSnackBar({
message: t`Interval switching has been cancelled.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error while cancelling interval switching.`,
});
} finally {
setIsCancellingIntervalSwitch(false);
}
};
const cancelMeteredSwitching = async () => {
if (isAnyActionLoading || isCancellingMeteredSwitch) return;
setIsCancellingMeteredSwitch(true);
try {
const { data } = await cancelSwitchMeteredPrice();
if (
isDefined(data?.cancelSwitchMeteredPrice?.currentBillingSubscription)
) {
refreshWorkspace(data.cancelSwitchMeteredPrice);
}
enqueueSuccessSnackBar({
message: t`Metered tier switching has been cancelled.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error while cancelling metered tier switching.`,
});
} finally {
setIsCancellingMeteredSwitch(false);
}
};
@@ -193,77 +343,220 @@ export const SettingsBillingSubscriptionInfo = () => {
<Section>
<H2Title title={t`Subscription`} description={t`About my subscription`} />
<SubscriptionInfoContainer>
<SubscriptionInfoHeaderRow show={hasNextBillingPhase} />
<SubscriptionInfoRowContainer
label={t`Plan`}
Icon={IconTag}
value={planTag}
currentValue={
<PlansTags
plan={currentPlan.planKey}
isTrialPeriod={isTrialPeriod}
/>
}
nextValue={
nextPlan ? (
<PlansTags
plan={nextPlan.planKey}
isTrialPeriod={isTrialPeriod}
/>
) : undefined
}
/>
<SubscriptionInfoRowContainer
label={t`Billing interval`}
Icon={IconCalendarEvent}
value={intervalLabel}
currentValue={getIntervalLabelAsAdjectiveCapitalize(
currentMeteredBillingPrice.recurringInterval ===
SubscriptionInterval.Month,
)}
nextValue={
nextInterval
? getIntervalLabelAsAdjectiveCapitalize(
nextInterval === SubscriptionInterval.Month,
)
: undefined
}
/>
{renewDate && (
{currentBillingSubscription.currentPeriodEnd && (
<SubscriptionInfoRowContainer
label={t`Renewal date`}
Icon={IconCalendarRepeat}
value={beautifyExactDate(renewDate)}
currentValue={getBeautifiedRenewDate()}
nextValue={
nextBillingPhase
? beautifyExactDate(nextBillingPhase.end_date * 1000)
: undefined
}
/>
)}
<SubscriptionInfoRowContainer
label={t`Seats`}
Icon={IconUsers}
value={seats}
currentValue={seats}
nextValue={nextBillingSeats}
/>
<SubscriptionInfoRowContainer
label={t`Credits by period`}
Icon={IconCoins}
currentValue={formatNumber(currentMeteredBillingPrice.tiers[0].upTo, {
abbreviate: true,
decimals: 2,
})}
nextValue={
nextMeteredBillingPrice
? formatNumber(nextMeteredBillingPrice.tiers[0].upTo, {
abbreviate: true,
decimals: 2,
})
: undefined
}
/>
</SubscriptionInfoContainer>
<StyledSwitchButtonContainer>
{isMonthlyPlan && (
<Button
Icon={IconArrowUp}
title={t`Switch to Yearly`}
variant="secondary"
onClick={() => openModal(SWITCH_BILLING_INTERVAL_MODAL_ID)}
disabled={!canSwitchSubscription}
/>
)}
{isProPlan && (
<Button
Icon={IconArrowUp}
title={t`Switch to Organization`}
variant="secondary"
onClick={() => openModal(SWITCH_BILLING_PLAN_MODAL_ID)}
disabled={!canSwitchSubscription}
/>
)}
{isTrialPeriod && hasPermissionToEndTrialPeriod && (
<Button
Icon={IconCircleX}
Icon={IconArrowUp}
title={t`Subscribe Now`}
variant="secondary"
onClick={() => openModal(END_TRIAL_PERIOD_MODAL_ID)}
disabled={isEndTrialPeriodLoading}
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}
/>
)}
{isMonthlyPlan &&
(!nextInterval ||
currentMeteredBillingPrice.recurringInterval === nextInterval) && (
<Button
Icon={IconArrowUp}
title={t`Switch to Yearly`}
variant="secondary"
onClick={() =>
openModal(SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID)
}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{isYearlyPlan &&
(!nextInterval ||
currentMeteredBillingPrice.recurringInterval === nextInterval) && (
<Button
Icon={IconArrowUp}
title={t`Switch to Monthly`}
variant="secondary"
onClick={() =>
openModal(SWITCH_BILLING_INTERVAL_TO_MONTHLY_MODAL_ID)
}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{isProPlan &&
(!nextPlan || currentPlan.planKey === nextPlan.planKey) && (
<Button
Icon={IconArrowUp}
title={t`Switch to Organization`}
variant="secondary"
onClick={() =>
openModal(SWITCH_BILLING_PLAN_TO_ENTERPRISE_MODAL_ID)
}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{isEnterprisePlan &&
(!nextPlan || currentPlan.planKey === nextPlan.planKey) && (
<Button
Icon={IconArrowUp}
title={t`Switch to Pro`}
variant="secondary"
onClick={() => openModal(SWITCH_BILLING_PLAN_TO_PRO_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{nextPlan && currentPlan.planKey !== nextPlan.planKey && (
<Button
Icon={IconCircleX}
title={t`Cancel plan switching`}
variant="secondary"
onClick={() => openModal(CANCEL_SWITCH_BILLING_PLAN_MODAL_ID)}
disabled={!canSwitchSubscription || isAnyActionLoading}
/>
)}
{/*@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}
/>
)}
</StyledSwitchButtonContainer>
<ConfirmationModal
modalId={SWITCH_BILLING_INTERVAL_MODAL_ID}
modalId={SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID}
title={t`Change to Yearly?`}
subtitle={t`You will be charged $${yearlyPrice} per user per month billed annually. A prorata with your current subscription will be applied.`}
subtitle={confirmationModalSwitchToYearlyMessage()}
onConfirmClick={switchInterval}
confirmButtonText={t`Confirm`}
confirmButtonAccent={'blue'}
loading={isSwitchingInterval}
/>
<ConfirmationModal
modalId={SWITCH_BILLING_INTERVAL_TO_MONTHLY_MODAL_ID}
title={t`Change to Monthly?`}
subtitle={confirmationModalSwitchToMonthlyMessage()}
onConfirmClick={switchInterval}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isSwitchingInterval}
/>
<ConfirmationModal
modalId={SWITCH_BILLING_PLAN_MODAL_ID}
modalId={CANCEL_SWITCH_BILLING_INTERVAL_MODAL_ID}
title={t`Cancel interval switching?`}
subtitle={confirmationModalCancelIntervalSwitchingMessage()}
onConfirmClick={cancelIntervalSwitching}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isCancellingIntervalSwitch}
/>
<ConfirmationModal
modalId={SWITCH_BILLING_PLAN_TO_ENTERPRISE_MODAL_ID}
title={t`Change to Organization Plan?`}
subtitle={
isYearlyPlan
? t`You will be charged $${enterprisePrice} per user per month billed annually.`
: t`You will be charged $${enterprisePrice} per user per month.`
}
subtitle={confirmationModalSwitchToOrganizationMessage()}
onConfirmClick={switchPlan}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isSwitchingPlan}
/>
<ConfirmationModal
modalId={SWITCH_BILLING_PLAN_TO_PRO_MODAL_ID}
title={t`Change to Pro Plan?`}
subtitle={confirmationModalSwitchToProMessage()}
onConfirmClick={switchPlan}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isSwitchingPlan}
/>
<ConfirmationModal
modalId={CANCEL_SWITCH_BILLING_PLAN_MODAL_ID}
title={t`Cancel plan switching?`}
subtitle={confirmationModalCancelPlanSwitchingMessage()}
onConfirmClick={cancelPlanSwitching}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isCancellingPlanSwitch}
/>
<ConfirmationModal
modalId={END_TRIAL_PERIOD_MODAL_ID}
@@ -274,6 +567,15 @@ export const SettingsBillingSubscriptionInfo = () => {
confirmButtonAccent="blue"
loading={isEndTrialPeriodLoading}
/>
<ConfirmationModal
modalId={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?`}
onConfirmClick={cancelMeteredSwitching}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
loading={isCancellingMeteredSwitch}
/>
</Section>
);
};
@@ -33,8 +33,6 @@ export const SubscriptionPrice = ({ type, price }: SubscriptionPriceProps) => {
case SubscriptionInterval.Year:
priceUnit = t`seat / month - billed yearly`;
break;
default:
priceUnit = `seat / ${type.toLocaleLowerCase()}`;
}
return (
@@ -1,99 +1,188 @@
import { useMutation } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { H2Title } from 'twenty-ui/display';
import { UPDATE_SUBSCRIPTION_ITEM_PRICE } from '@/billing/graphql/mutations/updateSubscriptionItemPrice';
import { findMeteredPriceInCurrentWorkspaceSubscriptions } from '@/billing/utils/findPriceInCurrentWorkspaceSubscriptions';
import { getIntervalLabel } from '@/billing/utils/subscriptionFlags';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useBillingWording } from '@/billing/hooks/useBillingWording';
import { useCurrentMetered } from '@/billing/hooks/useCurrentMetered';
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
import {
type BillingPriceTiers,
type MeteredBillingPrice,
} from '@/billing/types/billing-price-tiers.type';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import {
type BillingPriceOutput,
type BillingSubscriptionItem,
SubscriptionInterval,
} from '~/generated/graphql';
import { findOrThrow } from '~/utils/array/findOrThrow';
import { formatNumber } from '~/utils/format/formatNumber';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { findOrThrow, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { useSetMeteredSubscriptionPriceMutation } from '~/generated-metadata/graphql';
import { SubscriptionInterval } from '~/generated/graphql';
const compareByAmountAsc = (a: BillingPriceOutput, b: BillingPriceOutput) =>
a.amount - b.amount;
const StyledRow = styled.div`
align-items: flex-end;
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(2)};
`;
const toOption = (meteredBillingPrice: BillingPriceOutput) => {
const nickname = meteredBillingPrice.nickname;
const price = formatNumber(meteredBillingPrice.amount / 100, 2);
return {
label: t`${nickname} - ${price}$`,
value: meteredBillingPrice.stripePriceId,
};
};
const StyledSelect = styled(Select<string>)`
flex: 1 1;
`;
const StyledButton = styled(Button)`
flex: 0 0 auto;
`;
export const MeteredPriceSelector = ({
meteredBillingPrices,
billingSubscriptionItems,
isTrialing = false,
}: {
meteredBillingPrices: Array<BillingPriceOutput>;
billingSubscriptionItems: Array<BillingSubscriptionItem>;
meteredBillingPrices: Array<MeteredBillingPrice>;
isTrialing?: boolean;
}) => {
const [currentMeteredBillingPrice, setCurrentMeteredBillingPrice] = useState(
findMeteredPriceInCurrentWorkspaceSubscriptions(
billingSubscriptionItems,
meteredBillingPrices,
),
const { currentMeteredBillingPrice } = useCurrentMetered();
const { formatNumber } = useNumberFormat();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const { refetchMeteredProductsUsage } = useGetWorkflowNodeExecutionUsage();
const { getIntervalLabel } = useBillingWording();
const [currentMeteredPrice, setCurrentMeteredPrice] = useState(
currentMeteredBillingPrice,
);
const toOption = (meteredBillingPrice: MeteredBillingPrice) => {
const price = formatNumber(meteredBillingPrice.tiers[0].flatAmount / 100);
return {
label: `${formatNumber(meteredBillingPrice.tiers[0].upTo, { abbreviate: true, decimals: 2 })} Credits - $${price}`,
value: meteredBillingPrice.stripePriceId,
};
};
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [updateSubscriptionItemPrice, { loading: isUpdating }] = useMutation(
UPDATE_SUBSCRIPTION_ITEM_PRICE,
const [setMeteredSubscriptionPrice, { loading: isUpdating }] =
useSetMeteredSubscriptionPriceMutation();
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 options = useMemo(
() => [...meteredBillingPrices].sort(compareByAmountAsc).map(toOption),
[meteredBillingPrices],
const selectedPrice = meteredBillingPrices.find(
({ stripePriceId }) => stripePriceId === selectedPriceId,
);
const handleChange = async (priceId: string) => {
try {
await updateSubscriptionItemPrice({
variables: { priceId },
});
enqueueSuccessSnackBar({ message: t`Price updated.` });
setCurrentMeteredBillingPrice(
findOrThrow(
meteredBillingPrices,
({ stripePriceId }) => stripePriceId === priceId,
),
);
} catch {
enqueueErrorSnackBar({
message: t`Failed to update price.`,
});
}
const isChanged =
selectedPriceId && selectedPriceId !== currentMeteredPrice?.stripePriceId;
const isUpgrade = () => {
if (!isChanged || !selectedPrice || !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(
currentMeteredBillingPrice?.recurringInterval ===
SubscriptionInterval.Month,
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}`}
/>
<Select
dropdownId="settings-billing-metered-price"
options={options}
value={currentMeteredBillingPrice?.stripePriceId}
onChange={handleChange}
disabled={isUpdating || isTrialing}
description={
isTrialing ? t`Please start your subscription first` : undefined
}
<StyledRow>
<StyledSelect
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
/>
{isChanged && (
<StyledButton
title={isUpgrade() ? t`Upgrade` : t`Downgrade`}
onClick={handleOpenConfirm}
variant="primary"
isLoading={isUpdating}
disabled={!isChanged}
accent={isUpgrade() ? 'blue' : 'danger'}
/>
)}
</StyledRow>
<ConfirmationModal
modalId={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}
/>
</>
);
@@ -0,0 +1,29 @@
import React from 'react';
import { Tag } from 'twenty-ui/components';
import { t } from '@lingui/core/macro';
import { BillingPlanKey } from '~/generated-metadata/graphql';
import styled from '@emotion/styled';
export type PlansTagsProps = {
plan: BillingPlanKey;
isTrialPeriod?: boolean;
};
const StyledTagsWrapper = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const PlansTags = ({ plan, isTrialPeriod = false }: PlansTagsProps) => {
const planDescriptor =
plan === BillingPlanKey.PRO
? { color: 'sky' as const, label: t`Pro` }
: { color: 'purple' as const, label: t`Organization` };
return (
<StyledTagsWrapper>
<Tag color={planDescriptor.color} text={planDescriptor.label} />
{isTrialPeriod && <Tag color="blue" text={t`Trial`} preventShrink />}
</StyledTagsWrapper>
);
};
@@ -2,18 +2,21 @@ import { type IconComponent } from 'twenty-ui/display';
import React from 'react';
import styled from '@emotion/styled';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
type SubscriptionInfoRowContainerProps = {
Icon: IconComponent;
label: string;
value: React.ReactNode;
currentValue: React.ReactNode;
nextValue?: React.ReactNode;
};
const StyledContainer = styled.div`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
display: grid;
grid-template-columns: repeat(3, 1fr);
`;
const StyledIconLabelContainer = styled.div`
@@ -21,7 +24,6 @@ const StyledIconLabelContainer = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
width: 120px;
`;
const StyledLabelContainer = styled.div`
@@ -30,10 +32,27 @@ const StyledLabelContainer = styled.div`
white-space: nowrap;
`;
const StyledHeaderText = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
export const SubscriptionInfoHeaderRow = ({ show }: { show: boolean }) => {
if (!show) return null;
return (
<StyledContainer>
<div />
<StyledHeaderText>{t`Current`}</StyledHeaderText>
<StyledHeaderText>{t`Next`}</StyledHeaderText>
</StyledContainer>
);
};
export const SubscriptionInfoRowContainer = ({
Icon,
label,
value,
currentValue,
nextValue,
}: SubscriptionInfoRowContainerProps) => {
const theme = useTheme();
return (
@@ -42,7 +61,8 @@ export const SubscriptionInfoRowContainer = ({
<Icon size={theme.icon.size.md} />
<StyledLabelContainer>{label}</StyledLabelContainer>
</StyledIconLabelContainer>
{value}
{currentValue}
<div>{nextValue ?? ''}</div>
</StyledContainer>
);
};