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:
+20
@@ -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 />;
|
||||
};
|
||||
+36
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const START_SUBSCRIPTION_AFTER_PAYMENT_METHOD_QUERY_PARAM =
|
||||
'startSubscriptionAfterPaymentMethod';
|
||||
+87
@@ -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;
|
||||
};
|
||||
+8
-2
@@ -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,
|
||||
};
|
||||
};
|
||||
+75
-16
@@ -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.`,
|
||||
|
||||
+8
@@ -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,
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isEndingSubscriptionTrialPeriodState = createAtomState<boolean>({
|
||||
key: 'billing/isEndingSubscriptionTrialPeriodState',
|
||||
defaultValue: false,
|
||||
});
|
||||
Reference in New Issue
Block a user