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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+47
-37
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+437
-135
@@ -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 (
|
||||
|
||||
+155
-66
@@ -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>
|
||||
);
|
||||
};
|
||||
+25
-5
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user