feat(billing) - facilitate top up in ai chat (#21645)

Today, when a trialing user hits their AI usage cap inside the Ask AI
chat, ending the trial bounces them to the Stripe billing portal (and,
for card-less users, loses their place in the conversation). This PR
makes activating a paid plan / topping up credits feel seamless from
within the chat:

Trial users with a card on file activate their subscription in place,
without leaving the app.
Trial users without a card are sent to the Stripe payment-method portal
and, on return, the trial is ended automatically and they're dropped
back into the exact Ask AI thread they came from.
Credit-exhaustion and trial banners now reflect whether a payment method
exists (Add Credit Card vs Subscribe Now / End Trial Period) and upgrade
inline via a confirmation modal instead of redirecting to Settings.


Uploading Screen Recording 2026-06-16 at 07.51.12.mov…


https://github.com/user-attachments/assets/4ea77273-da63-4b32-b6f1-5ac9e9560651



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21645?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-06-17 18:20:11 +02:00
committed by GitHub
parent 177afde866
commit d99e479be8
48 changed files with 1562 additions and 837 deletions
@@ -1,21 +1,15 @@
import { AiChatBanner } from '@/ai/components/AiChatBanner';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { useGetNextResourceCreditPrice } from '@/settings/billing/hooks/useGetNextResourceCreditPrice';
import { useAiChatEndTrialPeriod } from '@/ai/hooks/useAiChatEndTrialPeriod';
import { StartSubscriptionConfirmationModal } from '@/settings/billing/components/StartSubscriptionConfirmationModal';
import { useCreditUpgradeAction } from '@/settings/billing/hooks/useCreditUpgradeAction';
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 { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import {
PermissionFlagType,
SetResourceCreditSubscriptionPriceDocument,
SubscriptionInterval,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
@@ -24,50 +18,32 @@ const AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID =
'ai-chat-upgrade-credit-plan-modal';
export const AIChatNoMoreBillingCreditsBanner = () => {
const { t } = useLingui();
const subscriptionStatus = useSubscriptionStatus();
const { openModal } = useModal();
const { endTrialPeriod, isLoading: isEndTrialLoading } =
useEndSubscriptionTrialPeriod();
const nextPrice = useGetNextResourceCreditPrice();
const { formatNumber } = useNumberFormat();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [setResourceCreditSubscriptionPrice, { loading: isUpgrading }] =
useMutation(SetResourceCreditSubscriptionPriceDocument);
const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
const { [PermissionFlagType.BILLING]: hasPermissionToManageBilling } =
usePermissionFlagMap();
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const { endTrialPeriodFromAiChat, isEndTrialLoading, hasPaymentMethod } =
useAiChatEndTrialPeriod();
const {
nextPrice,
nextResourceCreditsAmount,
nextResourceCreditPrice,
nextTierInterval,
upgradeCreditPlan,
isUpgrading,
} = useCreditUpgradeAction();
if (!hasPermissionToManageBilling) {
return null;
}
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const nextResourceCreditsAmount = isDefined(nextPrice)
? formatNumber(nextPrice.creditAmount ?? 0, {
abbreviate: true,
decimals: 2,
})
: null;
const nextResourceCreditPrice = isDefined(nextPrice)
? formatNumber((nextPrice.unitAmount ?? 0) / 100)
: null;
const nextTierInterval = isDefined(nextPrice)
? nextPrice.recurringInterval === SubscriptionInterval.Month
? t`month`
: t`year`
: null;
const message = isTrialing
? t`You've hit your usage limit. Subscribe for more usage.`
: isDefined(nextPrice)
@@ -75,7 +51,9 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
: t`You've hit your usage limit. \nReach to our support team to upgrade.`;
const buttonTitle = isTrialing
? t`Subscribe Now`
? hasPaymentMethod === false
? t`Add Credit Card`
: t`Subscribe Now`
: isDefined(nextPrice)
? t`Upgrade`
: undefined;
@@ -86,32 +64,6 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
? () => openModal(AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID)
: undefined;
const handleUpgradeConfirm = async () => {
if (!isDefined(nextPrice)) return;
try {
const { data } = await setResourceCreditSubscriptionPrice({
variables: { priceId: nextPrice.stripePriceId },
});
if (
isDefined(
data?.setResourceCreditSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
setCurrentWorkspace({
...currentWorkspace,
currentBillingSubscription:
data.setResourceCreditSubscriptionPrice.currentBillingSubscription,
billingSubscriptions:
data.setResourceCreditSubscriptionPrice.billingSubscriptions,
});
}
enqueueSuccessSnackBar({ message: t`Credit plan upgraded.` });
} catch {
enqueueErrorSnackBar({ message: t`Failed to upgrade credit plan.` });
}
};
return (
<>
<AiChatBanner
@@ -124,13 +76,10 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
}
/>
{isTrialing && (
<ConfirmationModal
<StartSubscriptionConfirmationModal
modalInstanceId={AI_CHAT_END_TRIAL_PERIOD_MODAL_ID}
title={t`Start Your Subscription`}
subtitle={t`We will activate your paid plan. Do you want to proceed?`}
onConfirmClick={endTrialPeriod}
confirmButtonText={t`Confirm`}
confirmButtonAccent="blue"
hasPaymentMethod={hasPaymentMethod}
onConfirmClick={endTrialPeriodFromAiChat}
loading={isEndTrialLoading}
/>
)}
@@ -139,7 +88,7 @@ export const AIChatNoMoreBillingCreditsBanner = () => {
modalInstanceId={AI_CHAT_UPGRADE_CREDIT_PLAN_MODAL_ID}
title={t`Get more credits`}
subtitle={t`Upgrade to ${nextResourceCreditsAmount ?? ''} credits for $${nextResourceCreditPrice ?? ''}/${nextTierInterval ?? ''}.`}
onConfirmClick={handleUpgradeConfirm}
onConfirmClick={upgradeCreditPlan}
confirmButtonText={t`Upgrade`}
confirmButtonAccent="blue"
loading={isUpgrading}
@@ -1,45 +0,0 @@
import { AiChatBanner } from '@/ai/components/AiChatBanner';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { IconSparkles } from 'twenty-ui/display';
import {
PermissionFlagType,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const AiChatCreditsExhaustedMessage = () => {
const navigateSettings = useNavigateSettings();
const subscriptionStatus = useSubscriptionStatus();
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
usePermissionFlagMap();
const handleUpgradeClick = () => {
navigateSettings(SettingsPath.Billing);
};
const message = hasPermissionToManageBilling
? isTrialing
? t`Free trial credits exhausted. Subscribe now to continue using AI features.`
: t`Credits exhausted. Upgrade your plan to get more credits.`
: t`Credits exhausted. Please contact your workspace admin to upgrade.`;
const buttonTitle = isTrialing ? t`Subscribe Now` : t`Upgrade Plan`;
return (
<AiChatBanner
message={message}
variant="warning"
buttonTitle={hasPermissionToManageBilling ? buttonTitle : undefined}
buttonIcon={IconSparkles}
buttonOnClick={
hasPermissionToManageBilling ? handleUpgradeClick : undefined
}
/>
);
};
@@ -1,5 +1,4 @@
import { AiChatApiKeyNotConfiguredMessage } from '@/ai/components/AiChatApiKeyNotConfiguredMessage';
import { AiChatCreditsExhaustedMessage } from '@/ai/components/AiChatCreditsExhaustedMessage';
import { AiChatErrorMessage } from '@/ai/components/AiChatErrorMessage';
import { type AiChatError } from '@/ai/types/AiChatError';
import { AiChatErrorCode } from '@/ai/utils/aiChatErrorCode';
@@ -11,7 +10,8 @@ type AiChatErrorRendererProps = {
export const AiChatErrorRenderer = ({ error }: AiChatErrorRendererProps) => {
if (isGraphqlErrorOfType(error, AiChatErrorCode.BILLING_CREDITS_EXHAUSTED)) {
return <AiChatCreditsExhaustedMessage />;
//Handle by AIChatNoMoreBillingCreditsBanner
return null;
}
if (isGraphqlErrorOfType(error, AiChatErrorCode.API_KEY_NOT_CONFIGURED)) {
@@ -17,11 +17,16 @@ const StyledScrollWrapperContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
overflow-y: auto;
padding: ${themeCssVariables.spacing[3]};
position: relative;
width: calc(100% - 24px);
width: 100%;
`;
const StyledMessageListContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[4]};
`;
export const AiChatTabMessageList = () => {
@@ -46,9 +51,11 @@ export const AiChatTabMessageList = () => {
}}
>
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
<AiChatNonLastMessageIdsList />
<AiChatLastMessageWithStreamingState />
<AiChatErrorUnderMessageList />
<StyledMessageListContent>
<AiChatNonLastMessageIdsList />
<AiChatLastMessageWithStreamingState />
<AiChatErrorUnderMessageList />
</StyledMessageListContent>
<AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect />
<AgentChatScrollToBottomOnMountLayoutEffect />
</ScrollWrapper>
@@ -0,0 +1 @@
export const ASK_AI_THREAD_ID_QUERY_PARAM = 'askAiThreadId';
@@ -20,10 +20,12 @@ import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMess
import { agentChatUsageComponentFamilyState } from '@/ai/states/agentChatUsageComponentFamilyState';
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
import { AiChatErrorCode } from '@/ai/utils/aiChatErrorCode';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { BillingProductKey } from '~/generated-metadata/graphql';
const THROTTLE_MS = 100;
@@ -324,6 +326,35 @@ export const useAgentChatSubscription = (threadId: string | null) => {
}
case 'credits-exhausted': {
//TODO : add real time on currentUser
store.set(currentWorkspaceState.atom, (currentWorkspace) => {
const currentBillingSubscription =
currentWorkspace?.currentBillingSubscription;
const billingSubscriptionItems =
currentBillingSubscription?.billingSubscriptionItems;
if (
!isDefined(currentWorkspace) ||
!isDefined(currentBillingSubscription) ||
!isDefined(billingSubscriptionItems)
) {
return currentWorkspace;
}
return {
...currentWorkspace,
currentBillingSubscription: {
...currentBillingSubscription,
billingSubscriptionItems: billingSubscriptionItems.map((item) =>
item.billingProduct.metadata?.['productKey'] ===
BillingProductKey.RESOURCE_CREDIT
? { ...item, hasReachedCurrentPeriodCap: true }
: item,
),
},
};
});
const noMoreCreditsError = new Error(
'Chat stopped: no more available credits.',
) as Error & { code?: string };
@@ -0,0 +1,32 @@
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { buildAskAiThreadRedirectPath } from '@/ai/utils/buildAskAiThreadRedirectPath';
import { billingHasPaymentMethodSelector } from '@/settings/billing/states/billingHasPaymentMethodSelector';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLocation } from 'react-router-dom';
export const useAiChatEndTrialPeriod = () => {
const location = useLocation();
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
const { endTrialPeriod, isLoading } = useEndSubscriptionTrialPeriod();
const billingHasPaymentMethod = useAtomStateValue(
billingHasPaymentMethodSelector,
);
const endTrialPeriodFromAiChat = async () => {
await endTrialPeriod({
finalRedirectPath: buildAskAiThreadRedirectPath({
pathname: location.pathname,
search: location.search,
threadId: currentAiChatThread,
}),
});
};
return {
endTrialPeriodFromAiChat,
isEndTrialLoading: isLoading,
hasPaymentMethod: billingHasPaymentMethod,
};
};
@@ -1,12 +1,9 @@
import { agentChatDraftsByThreadIdState } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatUsageComponentFamilyState } from '@/ai/states/agentChatUsageComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState';
import { useSwitchAgentChatThreadWithDraft } from '@/ai/hooks/useSwitchAgentChatThreadWithDraft';
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
@@ -23,13 +20,7 @@ export const useAiChatThreadClick = (
const setThreadIdCreatedFromDraft = useSetAtomState(
threadIdCreatedFromDraftState,
);
const [currentAiChatThread, setCurrentAiChatThread] = useAtomState(
currentAiChatThreadState,
);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const setAgentChatDraftsByThreadId = useSetAtomState(
agentChatDraftsByThreadIdState,
);
const { switchThreadWithDraft } = useSwitchAgentChatThreadWithDraft();
const threadTitleFamilyCallback = useAtomComponentFamilyStateCallbackState(
currentAiChatThreadTitleComponentFamilyState,
);
@@ -41,21 +32,8 @@ export const useAiChatThreadClick = (
const handleThreadClick = (thread: AgentChatThread) => {
setThreadIdCreatedFromDraft(null);
const isSameThread = thread.id === currentAiChatThread;
if (currentAiChatThread !== null) {
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[currentAiChatThread]: store.get(agentChatInputState.atom),
}));
}
setCurrentAiChatThread(thread.id);
if (!isSameThread) {
const newDraft =
store.get(agentChatDraftsByThreadIdState.atom)[thread.id] ?? '';
setAgentChatInput(newDraft);
}
switchThreadWithDraft(thread.id);
const clickedFamilyKey = { threadId: thread.id };
@@ -0,0 +1,37 @@
import { useAiChatThreadClick } from '@/ai/hooks/useAiChatThreadClick';
import { useSwitchAgentChatThreadWithDraft } from '@/ai/hooks/useSwitchAgentChatThreadWithDraft';
import { agentChatVisibleThreadsSelector } from '@/ai/states/selectors/agentChatVisibleThreadsSelector';
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
export const useOpenAskAiThread = () => {
const agentChatVisibleThreads = useAtomStateValue(
agentChatVisibleThreadsSelector,
);
const { switchThreadWithDraft } = useSwitchAgentChatThreadWithDraft();
const { handleThreadClick } = useAiChatThreadClick({
resetNavigationStack: true,
});
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
const openAskAiThread = (threadId: string) => {
const thread = agentChatVisibleThreads.find(
(visibleThread) => visibleThread.id === threadId,
);
if (isDefined(thread)) {
handleThreadClick(thread);
return;
}
if (isValidUuid(threadId)) {
switchThreadWithDraft(threadId);
}
openAskAiPage({ resetNavigationStack: true });
};
return { openAskAiThread };
};
@@ -0,0 +1,39 @@
import { agentChatDraftsByThreadIdState } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
export const useSwitchAgentChatThreadWithDraft = () => {
const [currentAiChatThread, setCurrentAiChatThread] = useAtomState(
currentAiChatThreadState,
);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const setAgentChatDraftsByThreadId = useSetAtomState(
agentChatDraftsByThreadIdState,
);
const store = useStore();
const switchThreadWithDraft = (toThreadId: string) => {
const isSameThread = toThreadId === currentAiChatThread;
if (isDefined(currentAiChatThread)) {
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[currentAiChatThread]: store.get(agentChatInputState.atom),
}));
}
setCurrentAiChatThread(toThreadId);
if (!isSameThread) {
const destinationDraft =
store.get(agentChatDraftsByThreadIdState.atom)[toThreadId] ?? '';
setAgentChatInput(destinationDraft);
}
};
return { switchThreadWithDraft };
};
@@ -1,47 +1,25 @@
import { useStore } from 'jotai';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { useSwitchAgentChatThreadWithDraft } from '@/ai/hooks/useSwitchAgentChatThreadWithDraft';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { shouldFocusChatEditorState } from '@/ai/states/shouldFocusChatEditorState';
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState';
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useSwitchToNewAiChat = () => {
const setThreadIdCreatedFromDraft = useSetAtomState(
threadIdCreatedFromDraftState,
);
const [currentAiChatThread, setCurrentAiChatThread] = useAtomState(
currentAiChatThreadState,
);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const setAgentChatDraftsByThreadId = useSetAtomState(
agentChatDraftsByThreadIdState,
);
const { switchThreadWithDraft } = useSwitchAgentChatThreadWithDraft();
const store = useStore();
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
const switchToNewChat = () => {
setThreadIdCreatedFromDraft(null);
const newChatDraft =
store.get(agentChatDraftsByThreadIdState.atom)[
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
] ?? '';
if (currentAiChatThread !== null) {
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[currentAiChatThread]: store.get(agentChatInputState.atom),
}));
}
store.set(hasTriggeredCreateForDraftState.atom, false);
setCurrentAiChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
setAgentChatInput(newChatDraft);
switchThreadWithDraft(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
openAskAiPage();
store.set(shouldFocusChatEditorState.atom, true);
};
@@ -0,0 +1,22 @@
import { ASK_AI_THREAD_ID_QUERY_PARAM } from '@/ai/constants/AskAiThreadIdQueryParam';
import { isNonEmptyString } from '@sniptt/guards';
export const buildAskAiThreadRedirectPath = ({
pathname,
search,
threadId,
}: {
pathname: string;
search: string;
threadId: string | null;
}): string => {
const searchParams = new URLSearchParams(search);
if (isNonEmptyString(threadId)) {
searchParams.set(ASK_AI_THREAD_ID_QUERY_PARAM, threadId);
}
const queryString = searchParams.toString();
return queryString.length > 0 ? `${pathname}?${queryString}` : pathname;
};
@@ -19,6 +19,7 @@ import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-compo
import { ApolloCoreProvider } from '@/object-metadata/components/ApolloCoreProvider';
import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { ApolloAdminProvider } from '@/settings/admin-panel/apollo/components/ApolloAdminProvider';
import { EndTrialAfterPaymentMethodGater } from '@/settings/billing/components/EndTrialAfterPaymentMethodGater';
import { CommandRunner } from '@/command-menu-item/engine-command/components/CommandRunner';
import { SSEProvider } from '@/sse-db-event/components/SSEProvider';
@@ -66,6 +67,7 @@ export const AppRouterProviders = () => {
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<EndTrialAfterPaymentMethodGater />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<PageFavicon />
@@ -16,6 +16,7 @@ export type CurrentWorkspace = Pick<
| 'activationStatus'
| 'billingSubscriptions'
| 'billingEntitlements'
| 'billingCustomer'
| 'currentBillingSubscription'
| 'workspaceMembersCount'
| 'isPublicInviteLinkEnabled'
@@ -1,31 +1,61 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { StartSubscriptionConfirmationModal } from '@/settings/billing/components/StartSubscriptionConfirmationModal';
import { billingHasPaymentMethodSelector } from '@/settings/billing/states/billingHasPaymentMethodSelector';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { PermissionFlagType } from '~/generated-metadata/graphql';
const INFORMATION_BANNER_END_TRIAL_PERIOD_MODAL_ID =
'information-banner-end-trial-period-modal';
export const InformationBannerEndTrialPeriod = () => {
const { endTrialPeriod, isLoading } = useEndSubscriptionTrialPeriod();
const { t } = useLingui();
const { openModal } = useModal();
const { [PermissionFlagType.WORKSPACE]: hasPermissionToEndTrialPeriod } =
const { [PermissionFlagType.BILLING]: hasPermissionToEndTrialPeriod } =
usePermissionFlagMap();
const billingHasPaymentMethod = useAtomStateValue(
billingHasPaymentMethodSelector,
);
return (
<InformationBanner
componentInstanceId="information-banner-end-trial-period"
color="danger"
variant="secondary"
message={
hasPermissionToEndTrialPeriod
? t`End trial period to continue using Workflow or AI features.`
: t`Contact your admin to continue using Workflow or AI features.`
}
buttonTitle={
hasPermissionToEndTrialPeriod ? t`End Trial Period` : undefined
}
buttonOnClick={async () => await endTrialPeriod()}
isButtonDisabled={isLoading}
/>
<>
<InformationBanner
componentInstanceId="information-banner-end-trial-period"
color="danger"
variant="secondary"
message={
hasPermissionToEndTrialPeriod
? t`End trial period to continue using Workflow or AI features.`
: t`Contact your admin to continue using Workflow or AI features.`
}
buttonTitle={
hasPermissionToEndTrialPeriod
? billingHasPaymentMethod === false
? t`Add Credit Card`
: t`End Trial Period`
: undefined
}
buttonOnClick={() =>
openModal(INFORMATION_BANNER_END_TRIAL_PERIOD_MODAL_ID)
}
isButtonDisabled={isLoading}
/>
{hasPermissionToEndTrialPeriod && (
<StartSubscriptionConfirmationModal
modalInstanceId={INFORMATION_BANNER_END_TRIAL_PERIOD_MODAL_ID}
hasPaymentMethod={billingHasPaymentMethod}
onConfirmClick={async () => {
await endTrialPeriod();
}}
loading={isLoading}
/>
)}
</>
);
};
@@ -1,30 +1,72 @@
import { InformationBanner } from '@/information-banner/components/InformationBanner';
import { useCreditUpgradeAction } from '@/settings/billing/hooks/useCreditUpgradeAction';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const INFORMATION_BANNER_UPGRADE_CREDIT_PLAN_MODAL_ID =
'information-banner-upgrade-credit-plan-modal';
export const InformationBannerNoMoreCredits = () => {
const { t } = useLingui();
const { [PermissionFlagType.WORKSPACE]: hasPermissionToUpdateCreditPlan } =
const { [PermissionFlagType.BILLING]: hasPermissionToUpdateCreditPlan } =
usePermissionFlagMap();
const navigateSettings = useNavigateSettings();
const { openModal } = useModal();
const {
nextPrice,
nextResourceCreditsAmount,
nextResourceCreditPrice,
nextTierInterval,
upgradeCreditPlan,
isUpgrading,
} = useCreditUpgradeAction();
const canUpgradeInline =
hasPermissionToUpdateCreditPlan && isDefined(nextPrice);
const buttonOnClick = !hasPermissionToUpdateCreditPlan
? undefined
: canUpgradeInline
? () => openModal(INFORMATION_BANNER_UPGRADE_CREDIT_PLAN_MODAL_ID)
: () => navigateSettings(SettingsPath.Billing);
return (
<InformationBanner
componentInstanceId="information-banner-no-more-credits"
color="danger"
variant="secondary"
message={
hasPermissionToUpdateCreditPlan
? t`Credits limit reached. Update your credit plan to keep Workflows and AI running.`
: t`Credits limit reached. Contact your admin to resume Workflows and AI.`
}
buttonTitle={hasPermissionToUpdateCreditPlan ? t`Update plan` : undefined}
buttonOnClick={async () => navigateSettings(SettingsPath.Billing)}
/>
<>
<InformationBanner
componentInstanceId="information-banner-no-more-credits"
color="danger"
variant="secondary"
message={
hasPermissionToUpdateCreditPlan
? t`Credits limit reached. Update your credit plan to keep Workflows and AI running.`
: t`Credits limit reached. Contact your admin to resume Workflows and AI.`
}
buttonTitle={
hasPermissionToUpdateCreditPlan ? t`Update plan` : undefined
}
buttonOnClick={buttonOnClick}
isButtonDisabled={isUpgrading}
/>
{canUpgradeInline && (
<ConfirmationModal
modalInstanceId={INFORMATION_BANNER_UPGRADE_CREDIT_PLAN_MODAL_ID}
title={t`Get more credits`}
subtitle={t`Upgrade to ${nextResourceCreditsAmount ?? ''} credits for $${nextResourceCreditPrice ?? ''}/${nextTierInterval ?? ''}.`}
onConfirmClick={upgradeCreditPlan}
confirmButtonText={t`Upgrade`}
confirmButtonAccent="blue"
loading={isUpgrading}
/>
)}
</>
);
};
@@ -0,0 +1,20 @@
import { billingState } from '@/client-config/states/billingState';
import { START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM } from '@/settings/billing/constants/StartSubscriptionAfterPaymentMethodQueryParam';
import { EndTrialAfterPaymentMethodEffect } from '@/settings/billing/effect-components/EndTrialAfterPaymentMethodEffect';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSearchParams } from 'react-router-dom';
export const EndTrialAfterPaymentMethodGater = () => {
const billing = useAtomStateValue(billingState);
const [searchParams] = useSearchParams();
const shouldRun =
(billing?.isBillingEnabled ?? false) &&
searchParams.has(START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM);
if (!shouldRun) {
return null;
}
return <EndTrialAfterPaymentMethodEffect />;
};
@@ -0,0 +1,36 @@
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro';
type StartSubscriptionConfirmationModalProps = {
modalInstanceId: string;
hasPaymentMethod: boolean | null | undefined;
onConfirmClick: () => Promise<void>;
loading: boolean;
};
export const StartSubscriptionConfirmationModal = ({
modalInstanceId,
hasPaymentMethod,
onConfirmClick,
loading,
}: StartSubscriptionConfirmationModalProps) => {
const needsCreditCard = hasPaymentMethod === false;
return (
<ConfirmationModal
modalInstanceId={modalInstanceId}
title={
needsCreditCard ? t`Add your credit card` : t`Start Your Subscription`
}
subtitle={
needsCreditCard
? t`You will be redirected to add your credit card. Once added, your subscription will start automatically.`
: t`We will activate your paid plan. Do you want to proceed?`
}
onConfirmClick={onConfirmClick}
confirmButtonText={needsCreditCard ? t`Add credit card` : t`Confirm`}
confirmButtonAccent="blue"
loading={loading}
/>
);
};
@@ -0,0 +1,2 @@
export const START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM =
'startSubscriptionAfterPaymentMethod';
@@ -0,0 +1,87 @@
import { ASK_AI_THREAD_ID_QUERY_PARAM } from '@/ai/constants/AskAiThreadIdQueryParam';
import { useOpenAskAiThread } from '@/ai/hooks/useOpenAskAiThread';
import { START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM } from '@/settings/billing/constants/StartSubscriptionAfterPaymentMethodQueryParam';
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
import { isEndingSubscriptionTrialPeriodState } from '@/settings/billing/states/isEndingSubscriptionTrialPeriodState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { isNonEmptyString } from '@sniptt/guards';
import { t } from '@lingui/core/macro';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
export const EndTrialAfterPaymentMethodEffect = () => {
const location = useLocation();
const navigate = useNavigate();
const subscriptionStatus = useSubscriptionStatus();
const { endTrialPeriod } = useEndSubscriptionTrialPeriod();
const { openAskAiThread } = useOpenAskAiThread();
const { enqueueErrorSnackBar } = useSnackBar();
const searchParams = new URLSearchParams(location.search);
const askAiThreadId = searchParams.get(ASK_AI_THREAD_ID_QUERY_PARAM);
const cleanUpQueryParams = () => {
searchParams.delete(START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM);
searchParams.delete(ASK_AI_THREAD_ID_QUERY_PARAM);
const cleanedSearch = searchParams.toString();
navigate(
`${location.pathname}${cleanedSearch.length > 0 ? `?${cleanedSearch}` : ''}${location.hash}`,
{ replace: true },
);
};
const startSubscription = async () => {
if (subscriptionStatus !== SubscriptionStatus.Trialing) {
cleanUpQueryParams();
return;
}
if (jotaiStore.get(isEndingSubscriptionTrialPeriodState.atom) === true) {
return;
}
jotaiStore.set(isEndingSubscriptionTrialPeriodState.atom, true);
try {
const { success, hasPaymentMethod } = await endTrialPeriod({
skipPaymentMethodRedirect: true,
});
if (success) {
if (isNonEmptyString(askAiThreadId)) {
openAskAiThread(askAiThreadId);
}
} else if (hasPaymentMethod === false) {
enqueueErrorSnackBar({
message: t`No payment method found. Please update your billing details.`,
});
}
} finally {
cleanUpQueryParams();
jotaiStore.set(isEndingSubscriptionTrialPeriodState.atom, false);
}
};
useEffect(() => {
if (!isDefined(subscriptionStatus)) {
return;
}
void startSubscription();
}, [
location.search,
location.pathname,
location.hash,
navigate,
subscriptionStatus,
endTrialPeriod,
openAskAiThread,
enqueueErrorSnackBar,
]);
return null;
};
@@ -1,8 +1,14 @@
import { gql } from '@apollo/client';
export const BILLING_PORTAL_SESSION = gql`
query BillingPortalSession($returnUrlPath: String) {
billingPortalSession(returnUrlPath: $returnUrlPath) {
query BillingPortalSession(
$returnUrlPath: String
$forPaymentMethodUpdate: Boolean
) {
billingPortalSession(
returnUrlPath: $returnUrlPath
forPaymentMethodUpdate: $forPaymentMethodUpdate
) {
url
}
}
@@ -0,0 +1,96 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useGetNextResourceCreditPrice } from '@/settings/billing/hooks/useGetNextResourceCreditPrice';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
SetResourceCreditSubscriptionPriceDocument,
SubscriptionInterval,
} from '~/generated-metadata/graphql';
export const useCreditUpgradeAction = () => {
const nextPrice = useGetNextResourceCreditPrice();
const { formatNumber } = useNumberFormat();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar, enqueueInfoSnackBar } =
useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [setResourceCreditSubscriptionPrice, { loading: isUpgrading }] =
useMutation(SetResourceCreditSubscriptionPriceDocument);
const nextResourceCreditsAmount = isDefined(nextPrice)
? formatNumber(nextPrice.creditAmount ?? 0, {
abbreviate: true,
decimals: 2,
})
: null;
const nextResourceCreditPrice = isDefined(nextPrice)
? formatNumber((nextPrice.unitAmount ?? 0) / 100)
: null;
const nextTierInterval = isDefined(nextPrice)
? nextPrice.recurringInterval === SubscriptionInterval.Month
? t`month`
: t`year`
: null;
const upgradeCreditPlan = async () => {
if (!isDefined(nextPrice)) {
return;
}
try {
enqueueInfoSnackBar({
message: t`Upgrading subscription...`,
});
const { data } = await setResourceCreditSubscriptionPrice({
variables: { priceId: nextPrice.stripePriceId },
});
if (
isDefined(
data?.setResourceCreditSubscriptionPrice.currentBillingSubscription,
) &&
isDefined(currentWorkspace)
) {
setCurrentWorkspace({
...currentWorkspace,
currentBillingSubscription: {
...data.setResourceCreditSubscriptionPrice
.currentBillingSubscription,
billingSubscriptionItems:
data.setResourceCreditSubscriptionPrice.currentBillingSubscription?.billingSubscriptionItems?.map(
(item) => ({
...item,
hasReachedCurrentPeriodCap: false,
}),
),
},
billingSubscriptions:
data.setResourceCreditSubscriptionPrice.billingSubscriptions,
});
}
enqueueSuccessSnackBar({ message: t`Credit plan upgraded.` });
} catch {
enqueueErrorSnackBar({ message: t`Failed to upgrade credit plan.` });
}
};
return {
nextPrice,
nextResourceCreditsAmount,
nextResourceCreditPrice,
nextTierInterval,
upgradeCreditPlan,
isUpgrading,
};
};
@@ -1,47 +1,99 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM } from '@/settings/billing/constants/StartSubscriptionAfterPaymentMethodQueryParam';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { EndSubscriptionTrialPeriodDocument } from '~/generated-metadata/graphql';
import { useLazyQuery, useMutation } from '@apollo/client/react';
import {
BillingPortalSessionDocument,
EndSubscriptionTrialPeriodDocument,
} from '~/generated-metadata/graphql';
export const useEndSubscriptionTrialPeriod = () => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar, enqueueInfoSnackBar } =
useSnackBar();
const [endSubscriptionTrialPeriod] = useMutation(
EndSubscriptionTrialPeriodDocument,
);
const [getBillingPortalSession] = useLazyQuery(BillingPortalSessionDocument);
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [isLoading, setIsLoading] = useState(false);
const { redirect } = useRedirect();
const location = useLocation();
const endTrialPeriod = async () => {
const redirectToPaymentMethodUpdate = async (
fallbackUrl: string | null | undefined,
finalRedirectPath: string,
) => {
const returnUrl = new URL(finalRedirectPath, 'https://placeholder.invalid');
returnUrl.searchParams.set(
START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM,
'true',
);
const confirmReturnPath = `${returnUrl.pathname}${returnUrl.search}${returnUrl.hash}`;
try {
const { data } = await getBillingPortalSession({
variables: {
returnUrlPath: confirmReturnPath,
forPaymentMethodUpdate: true,
},
});
const portalUrl = data?.billingPortalSession.url ?? fallbackUrl;
if (isDefined(portalUrl)) {
redirect(portalUrl);
return;
}
} catch {
if (isDefined(fallbackUrl)) {
redirect(fallbackUrl);
return;
}
}
enqueueErrorSnackBar({
message: t`No payment method found. Please update your billing details.`,
});
};
const endTrialPeriod = async (options?: {
finalRedirectPath?: string;
skipPaymentMethodRedirect?: boolean;
}): Promise<{ success: boolean; hasPaymentMethod?: boolean }> => {
try {
setIsLoading(true);
if (options?.skipPaymentMethodRedirect === true) {
enqueueInfoSnackBar({
message: t`Activating subscription...`,
});
}
const finalRedirectPath =
options?.finalRedirectPath ?? `${location.pathname}${location.search}`;
const { data } = await endSubscriptionTrialPeriod();
const endTrialPeriodOutput = data?.endSubscriptionTrialPeriod;
const hasPaymentMethod = endTrialPeriodOutput?.hasPaymentMethod;
if (isDefined(hasPaymentMethod) && hasPaymentMethod === false) {
const billingPortalUrl = endTrialPeriodOutput?.billingPortalUrl;
if (isDefined(billingPortalUrl)) {
redirect(billingPortalUrl);
return { success: false };
if (options?.skipPaymentMethodRedirect !== true) {
await redirectToPaymentMethodUpdate(
endTrialPeriodOutput?.billingPortalUrl,
finalRedirectPath,
);
}
enqueueErrorSnackBar({
message: t`No payment method found. Please update your billing details.`,
});
return { success: false };
return { success: false, hasPaymentMethod: false };
}
const updatedSubscriptionStatus = endTrialPeriodOutput?.status;
@@ -54,6 +106,13 @@ export const useEndSubscriptionTrialPeriod = () => {
currentBillingSubscription: {
...currentWorkspace?.currentBillingSubscription,
status: updatedSubscriptionStatus,
billingSubscriptionItems:
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems?.map(
(item) => ({
...item,
hasReachedCurrentPeriodCap: false,
}),
),
},
});
}
@@ -62,7 +121,7 @@ export const useEndSubscriptionTrialPeriod = () => {
message: t`Subscription activated.`,
});
return { success: true };
return { success: true, hasPaymentMethod: true };
} catch {
enqueueErrorSnackBar({
message: t`Error while ending trial period. Please contact Twenty team.`,
@@ -0,0 +1,8 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const billingHasPaymentMethodSelector = createAtomSelector({
key: 'billingHasPaymentMethodSelector',
get: ({ get }) =>
get(currentWorkspaceState)?.billingCustomer?.hasPaymentMethod,
});
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isEndingSubscriptionTrialPeriodState = createAtomState<boolean>({
key: 'billing/isEndingSubscriptionTrialPeriodState',
defaultValue: false,
});
@@ -83,6 +83,10 @@ export const USER_QUERY_FRAGMENT = gql`
currentBillingSubscription {
...CurrentBillingSubscriptionFragment
}
billingCustomer {
id
hasPaymentMethod
}
billingSubscriptions {
...BillingSubscriptionFragment
}