martmull
2025-06-05 20:56:55 +02:00
committed by GitHub
parent c75f10bc33
commit b2c57c5dcc
29 changed files with 650 additions and 237 deletions

View File

@ -10,18 +10,7 @@ import { BACKGROUND_LIGHT, COLOR } from 'twenty-ui/theme';
import { SubscriptionStatus } from '~/generated/graphql';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
const StyledMonthlyCreditsContainer = styled.div`
border-radius: ${({ theme }) => theme.border.radius.md};
border: 1px solid ${({ theme }) => theme.border.color.medium};
background-color: ${({ theme }) => theme.background.secondary};
padding: ${({ theme }) => theme.spacing(3)};
width: 100%;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
`;
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
const StyledLineSeparator = styled.div`
width: 100%;
@ -57,7 +46,7 @@ export const SettingsBillingMonthlyCreditsSection = () => {
title={t`Monthly Credits`}
description={t`Track your monthly workflow credit consumption.`}
/>
<StyledMonthlyCreditsContainer>
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`Free Credits Used`}
value={`${formattedFreeUsageQuantity}/${formatAmount(includedFreeQuantity)}`}
@ -91,7 +80,7 @@ export const SettingsBillingMonthlyCreditsSection = () => {
value={`$${formatNumber(totalCostCents / 100, 2)}`}
/>
)}
</StyledMonthlyCreditsContainer>
</SubscriptionInfoContainer>
</Section>
);
};

View File

@ -0,0 +1,238 @@
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
import { SubscriptionInfoRowContainer } from '@/billing/components/SubscriptionInfoRowContainer';
import {
H2Title,
IconCalendarEvent,
IconTag,
IconUsers,
IconArrowUp,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import {
BillingPlanKey,
BillingPlanOutput,
BillingProductKey,
SubscriptionInterval,
SubscriptionStatus,
useBillingBaseProductPricesQuery,
useSwitchSubscriptionToEnterprisePlanMutation,
useSwitchSubscriptionToYearlyIntervalMutation,
} from '~/generated/graphql';
import { useRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Tag } from 'twenty-ui/components';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { formatMonthlyPrices } from '@/billing/utils/formatMonthlyPrices';
import { isDefined } from 'twenty-shared/utils';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
const SWITCH_BILLING_INTERVAL_MODAL_ID = 'switch-billing-interval-modal';
const SWITCH_BILLING_PLAN_MODAL_ID = 'switch-billing-plan-modal';
const StyledSwitchButtonContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-top: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsBillingSubscriptionInfo = () => {
const { t } = useLingui();
const { openModal } = useModal();
const { enqueueSnackBar } = useSnackBar();
const subscriptionStatus = useSubscriptionStatus();
const { data: pricesData } = useBillingBaseProductPricesQuery();
const [switchToYearlyInterval] =
useSwitchSubscriptionToYearlyIntervalMutation();
const [switchToEnterprisePlan] =
useSwitchSubscriptionToEnterprisePlanMutation();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const isMonthlyPlan =
currentWorkspace?.currentBillingSubscription?.interval ===
SubscriptionInterval.Month;
const isYearlyPlan =
currentWorkspace?.currentBillingSubscription?.interval ===
SubscriptionInterval.Year;
const isProPlan =
currentWorkspace?.currentBillingSubscription?.metadata['plan'] ===
BillingPlanKey.PRO;
const isEnterprisePlan =
currentWorkspace?.currentBillingSubscription?.metadata['plan'] ===
BillingPlanKey.ENTERPRISE;
const canSwitchSubscription =
subscriptionStatus !== SubscriptionStatus.PastDue;
const planTag = isProPlan ? (
<Tag color={'sky'} text={t`Pro`} />
) : isEnterprisePlan ? (
<Tag color={'purple'} text={t`Organization`} />
) : undefined;
const intervalLabel = isMonthlyPlan
? t`Monthly`
: isYearlyPlan
? t`Yearly`
: undefined;
const seats =
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems?.find(
(item) =>
item.billingProduct?.metadata.productKey ===
BillingProductKey.BASE_PRODUCT,
)?.quantity as number | undefined;
const baseProductPrices = pricesData?.plans as BillingPlanOutput[];
const formattedPrices = formatMonthlyPrices(baseProductPrices);
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 switchInterval = async () => {
try {
await switchToYearlyInterval();
if (isDefined(currentWorkspace?.currentBillingSubscription)) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription: {
...currentWorkspace?.currentBillingSubscription,
interval: SubscriptionInterval.Year,
},
};
setCurrentWorkspace(newCurrentWorkspace);
}
enqueueSnackBar(t`Subscription has been switched to Yearly.`, {
variant: SnackBarVariant.Success,
});
} catch (error: any) {
enqueueSnackBar(t`Error while switching subscription to Yearly.`, {
variant: SnackBarVariant.Error,
});
}
};
const switchPlan = async () => {
try {
await switchToEnterprisePlan();
if (isDefined(currentWorkspace?.currentBillingSubscription)) {
const newCurrentWorkspace = {
...currentWorkspace,
currentBillingSubscription: {
...currentWorkspace?.currentBillingSubscription,
metadata: {
...currentWorkspace?.currentBillingSubscription.metadata,
plan: BillingPlanKey.ENTERPRISE,
},
},
};
setCurrentWorkspace(newCurrentWorkspace);
}
enqueueSnackBar(t`Subscription has been switched to Organization Plan.`, {
variant: SnackBarVariant.Success,
});
} catch (error: any) {
enqueueSnackBar(
t`Error while switching subscription to Organization Plan.`,
{
variant: SnackBarVariant.Error,
},
);
}
};
return (
<Section>
<H2Title title={t`Subscription`} description={t`About my subscription`} />
<SubscriptionInfoContainer>
<SubscriptionInfoRowContainer
label={t`Plan`}
Icon={IconTag}
value={planTag}
/>
<SubscriptionInfoRowContainer
label={t`Billing interval`}
Icon={IconCalendarEvent}
value={intervalLabel}
/>
<SubscriptionInfoRowContainer
label={t`Seats`}
Icon={IconUsers}
value={seats}
/>
</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}
/>
)}
</StyledSwitchButtonContainer>
<ConfirmationModal
modalId={SWITCH_BILLING_INTERVAL_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.`}
onConfirmClick={switchInterval}
confirmButtonText={t`Confirm`}
confirmButtonAccent={'blue'}
/>
<ConfirmationModal
modalId={SWITCH_BILLING_PLAN_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.`
}
onConfirmClick={switchPlan}
confirmButtonText={t`Confirm`}
confirmButtonAccent={'blue'}
/>
</Section>
);
};

View File

@ -0,0 +1,14 @@
import styled from '@emotion/styled';
const StyledSubscriptionInfoContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
padding: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
export { StyledSubscriptionInfoContainer as SubscriptionInfoContainer };

View File

@ -0,0 +1,48 @@
import { IconComponent } from 'twenty-ui/display';
import React from 'react';
import styled from '@emotion/styled';
import { useTheme } from '@emotion/react';
type SubscriptionInfoRowContainerProps = {
Icon: IconComponent;
label: string;
value: React.ReactNode;
};
const StyledContainer = styled.div`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
`;
const StyledIconLabelContainer = styled.div`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
width: 120px;
`;
const StyledLabelContainer = styled.div`
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`;
export const SubscriptionInfoRowContainer = ({
Icon,
label,
value,
}: SubscriptionInfoRowContainerProps) => {
const theme = useTheme();
return (
<StyledContainer>
<StyledIconLabelContainer>
<Icon size={theme.icon.size.md} />
<StyledLabelContainer>{label}</StyledLabelContainer>
</StyledIconLabelContainer>
{value}
</StyledContainer>
);
};

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const SWITCH_SUBSCRIPTION_TO_ENTERPRISE_PLAN = gql`
mutation SwitchSubscriptionToEnterprisePlan {
switchToEnterprisePlan {
success
}
}
`;

View File

@ -0,0 +1,46 @@
import {
BillingPlanKey,
SubscriptionInterval,
BillingPlanOutput,
BillingPriceLicensedDto,
} from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
export const formatMonthlyPrices = (plans: BillingPlanOutput[] | undefined) => {
if (!isDefined(plans)) {
return;
}
const enterprisePlan = plans.find(
(plan) => plan.planKey === BillingPlanKey.ENTERPRISE,
);
const enterpriseYearPrice = enterprisePlan?.baseProduct.prices?.find(
(price) => price.recurringInterval === SubscriptionInterval.Year,
) as BillingPriceLicensedDto;
const enterpriseMonthPrice = enterprisePlan?.baseProduct.prices?.find(
(price) => price.recurringInterval === SubscriptionInterval.Month,
) as BillingPriceLicensedDto;
const proPlan = plans.find((plan) => plan.planKey === BillingPlanKey.PRO);
const proYearPrice = proPlan?.baseProduct.prices?.find(
(price) => price.recurringInterval === SubscriptionInterval.Year,
) as BillingPriceLicensedDto;
const proMonthPrice = proPlan?.baseProduct.prices?.find(
(price) => price.recurringInterval === SubscriptionInterval.Month,
) as BillingPriceLicensedDto;
return {
[BillingPlanKey.ENTERPRISE]: {
[SubscriptionInterval.Year]: enterpriseYearPrice?.unitAmount / 100 / 12,
[SubscriptionInterval.Month]: enterpriseMonthPrice?.unitAmount / 100,
},
[BillingPlanKey.PRO]: {
[SubscriptionInterval.Year]: proYearPrice?.unitAmount / 100 / 12,
[SubscriptionInterval.Month]: proMonthPrice?.unitAmount / 100,
},
};
};

View File

@ -38,11 +38,13 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
id: '1',
interval: SubscriptionInterval.Month,
status: SubscriptionStatus.Active,
metadata: {},
},
billingSubscriptions: [
{
id: '1',
status: SubscriptionStatus.Active,
metadata: {},
},
],
});

View File

@ -37,7 +37,7 @@ const renderHooks = (
result.current.setCurrentWorkspace({
...mockCurrentWorkspace,
currentBillingSubscription: withCurrentBillingSubscription
? { id: v4(), status: SubscriptionStatus.Active }
? { id: v4(), status: SubscriptionStatus.Active, metadata: {} }
: undefined,
workspaceMembersCount: withOneWorkspaceMember ? 1 : 2,
});

View File

@ -60,9 +60,11 @@ export const USER_QUERY_FRAGMENT = gql`
id
status
interval
metadata
billingSubscriptionItems {
id
hasReachedCurrentPeriodCap
quantity
billingProduct {
name
description
@ -77,6 +79,7 @@ export const USER_QUERY_FRAGMENT = gql`
billingSubscriptions {
id
status
metadata
}
workspaceMembersCount
defaultRole {

View File

@ -50,6 +50,7 @@ describe('useSubscriptionStatus', () => {
currentBillingSubscription: {
id: v4(),
status: subscriptionStatus,
metadata: {},
},
});
});