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
@@ -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);
};