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>