d2ddd6f473
Moves system-level operations (auth, billing, admin) to use the /metadata endpoint instead of /graphql. This cleans up the endpoint separation so /graphql is purely for core objects (Company, People, etc.) and /metadata handles all system operations. Part of prep work for webhook/API key core migration.
239 lines
7.6 KiB
TypeScript
239 lines
7.6 KiB
TypeScript
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
|
import { SubscriptionInfoRowContainer } from '@/billing/components/SubscriptionInfoRowContainer';
|
|
|
|
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
|
import { formatMonthlyPrices } from '@/billing/utils/formatMonthlyPrices';
|
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
|
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 { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
|
import styled from '@emotion/styled';
|
|
import { useLingui } from '@lingui/react/macro';
|
|
import { useRecoilState } from 'recoil';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { Tag } from 'twenty-ui/components';
|
|
import {
|
|
H2Title,
|
|
IconArrowUp,
|
|
IconCalendarEvent,
|
|
IconTag,
|
|
IconUsers,
|
|
} from 'twenty-ui/display';
|
|
import { Button } from 'twenty-ui/input';
|
|
import { Section } from 'twenty-ui/layout';
|
|
import {
|
|
BillingPlanKey,
|
|
BillingPlanOutput,
|
|
BillingProductKey,
|
|
SubscriptionInterval,
|
|
SubscriptionStatus,
|
|
useBillingBaseProductPricesQuery,
|
|
useSwitchSubscriptionToEnterprisePlanMutation,
|
|
useSwitchSubscriptionToYearlyIntervalMutation,
|
|
} from '~/generated-metadata/graphql';
|
|
|
|
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>
|
|
);
|
|
};
|