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:
+3
-1
@@ -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`,
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
+49
-20
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+93
-48
@@ -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"
|
||||
|
||||
+17
-15
@@ -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,
|
||||
);
|
||||
|
||||
+209
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -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,
|
||||
};
|
||||
};
|
||||
+67
@@ -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) =>
|
||||
|
||||
+24
-7
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user