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:
@@ -964,6 +964,7 @@ type Workspace {
|
||||
billingSubscriptions: [BillingSubscription!]!
|
||||
installedApplications: [Application!]!
|
||||
currentBillingSubscription: BillingSubscription
|
||||
billingCustomer: BillingCustomer
|
||||
billingEntitlements: [BillingEntitlement!]!
|
||||
hasValidSignedEnterpriseKey: Boolean!
|
||||
hasValidEnterpriseValidityToken: Boolean!
|
||||
@@ -1554,6 +1555,11 @@ type BillingSubscriptionItem {
|
||||
billingProduct: BillingProductDTO!
|
||||
}
|
||||
|
||||
type BillingCustomer {
|
||||
id: UUID!
|
||||
hasPaymentMethod: Boolean
|
||||
}
|
||||
|
||||
type BillingSubscription {
|
||||
id: UUID!
|
||||
status: SubscriptionStatus!
|
||||
@@ -3029,7 +3035,7 @@ type Query {
|
||||
apiKey(input: GetApiKeyInput!): ApiKey
|
||||
getInviteSuggestions: [InviteSuggestion!]!
|
||||
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
|
||||
billingPortalSession(returnUrlPath: String): BillingSession!
|
||||
billingPortalSession(returnUrlPath: String, forPaymentMethodUpdate: Boolean): BillingSession!
|
||||
listPlans: [BillingPlan!]!
|
||||
getResourceCreditUsage: [BillingResourceCreditUsage!]!
|
||||
findWorkspaceInvitations: [WorkspaceInvitation!]!
|
||||
|
||||
@@ -682,6 +682,7 @@ export interface Workspace {
|
||||
billingSubscriptions: BillingSubscription[]
|
||||
installedApplications: Application[]
|
||||
currentBillingSubscription?: BillingSubscription
|
||||
billingCustomer?: BillingCustomer
|
||||
billingEntitlements: BillingEntitlement[]
|
||||
hasValidSignedEnterpriseKey: Scalars['Boolean']
|
||||
hasValidEnterpriseValidityToken: Scalars['Boolean']
|
||||
@@ -1212,6 +1213,12 @@ export interface BillingSubscriptionItem {
|
||||
__typename: 'BillingSubscriptionItem'
|
||||
}
|
||||
|
||||
export interface BillingCustomer {
|
||||
id: Scalars['UUID']
|
||||
hasPaymentMethod?: Scalars['Boolean']
|
||||
__typename: 'BillingCustomer'
|
||||
}
|
||||
|
||||
export interface BillingSubscription {
|
||||
id: Scalars['UUID']
|
||||
status: SubscriptionStatus
|
||||
@@ -3691,6 +3698,7 @@ export interface WorkspaceGenqlSelection{
|
||||
billingSubscriptions?: BillingSubscriptionGenqlSelection
|
||||
installedApplications?: ApplicationGenqlSelection
|
||||
currentBillingSubscription?: BillingSubscriptionGenqlSelection
|
||||
billingCustomer?: BillingCustomerGenqlSelection
|
||||
billingEntitlements?: BillingEntitlementGenqlSelection
|
||||
hasValidSignedEnterpriseKey?: boolean | number
|
||||
hasValidEnterpriseValidityToken?: boolean | number
|
||||
@@ -4246,6 +4254,13 @@ export interface BillingSubscriptionItemGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface BillingCustomerGenqlSelection{
|
||||
id?: boolean | number
|
||||
hasPaymentMethod?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface BillingSubscriptionGenqlSelection{
|
||||
id?: boolean | number
|
||||
status?: boolean | number
|
||||
@@ -5782,7 +5797,7 @@ export interface QueryGenqlSelection{
|
||||
apiKey?: (ApiKeyGenqlSelection & { __args: {input: GetApiKeyInput} })
|
||||
getInviteSuggestions?: InviteSuggestionGenqlSelection
|
||||
applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
|
||||
billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null)} })
|
||||
billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null), forPaymentMethodUpdate?: (Scalars['Boolean'] | null)} })
|
||||
listPlans?: BillingPlanGenqlSelection
|
||||
getResourceCreditUsage?: BillingResourceCreditUsageGenqlSelection
|
||||
findWorkspaceInvitations?: WorkspaceInvitationGenqlSelection
|
||||
@@ -7272,6 +7287,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const BillingCustomer_possibleTypes: string[] = ['BillingCustomer']
|
||||
export const isBillingCustomer = (obj?: { __typename?: any } | null): obj is BillingCustomer => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingCustomer"')
|
||||
return BillingCustomer_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const BillingSubscription_possibleTypes: string[] = ['BillingSubscription']
|
||||
export const isBillingSubscription = (obj?: { __typename?: any } | null): obj is BillingSubscription => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSubscription"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+27
-78
@@ -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'
|
||||
|
||||
+46
-16
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+55
-13
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+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,
|
||||
});
|
||||
@@ -83,6 +83,10 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
currentBillingSubscription {
|
||||
...CurrentBillingSubscriptionFragment
|
||||
}
|
||||
billingCustomer {
|
||||
id
|
||||
hasPaymentMethod
|
||||
}
|
||||
billingSubscriptions {
|
||||
...BillingSubscriptionFragment
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.15.0', 1781280240009)
|
||||
export class AddHasPaymentMethodToBillingCustomerFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const tableExists = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'billingCustomer'`,
|
||||
);
|
||||
|
||||
if (tableExists.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."billingCustomer" ADD COLUMN IF NOT EXISTS "hasPaymentMethod" boolean`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const tableExists = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'billingCustomer'`,
|
||||
);
|
||||
|
||||
if (tableExists.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."billingCustomer" DROP COLUMN IF EXISTS "hasPaymentMethod"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -72,6 +72,7 @@ import { EmailingDomainTenantStatusAndGlobalUniquenessFastInstanceCommand } from
|
||||
import { AddLogicFunctionExecutionModeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799000030000-add-logic-function-execution-mode';
|
||||
import { EncryptNonSecretApplicationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1798400000000-encrypt-non-secret-application-variable';
|
||||
import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences';
|
||||
import { AddHasPaymentMethodToBillingCustomerFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-instance-command-fast-1781280240009-add-has-payment-method-to-billing-customer';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -135,6 +136,7 @@ export const INSTANCE_COMMANDS = [
|
||||
EncryptNonSecretApplicationVariableSlowInstanceCommand,
|
||||
DropIsCustomFromObjectAndFieldMetadataFastInstanceCommand,
|
||||
DropEmailingDomainDriverColumnFastInstanceCommand,
|
||||
AddHasPaymentMethodToBillingCustomerFastInstanceCommand,
|
||||
AddEmailingDomainUnsubscribeHostFastInstanceCommand,
|
||||
ViewOverridableEntityFastInstanceCommand,
|
||||
CreateMessageSuppressionCoreTableFastInstanceCommand,
|
||||
|
||||
+3
-1
@@ -123,8 +123,10 @@ export class BillingWebhookController {
|
||||
);
|
||||
|
||||
case BillingWebhookEvent.CUSTOMER_CREATED:
|
||||
case BillingWebhookEvent.PAYMENT_METHOD_ATTACHED:
|
||||
case BillingWebhookEvent.PAYMENT_METHOD_DETACHED:
|
||||
return await this.billingWebhookCustomerService.processStripeEvent(
|
||||
event.data,
|
||||
event,
|
||||
);
|
||||
|
||||
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED:
|
||||
|
||||
+109
-1
@@ -1,6 +1,9 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
@@ -9,17 +12,44 @@ import {
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
|
||||
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isString } from '@sniptt/guards';
|
||||
@Injectable()
|
||||
export class BillingWebhookCustomerService {
|
||||
protected readonly logger = new Logger(BillingWebhookCustomerService.name);
|
||||
constructor(
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository -- resolves workspaceId from a Stripe customerId before any workspace context exists
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepositoryUnscoped: Repository<BillingCustomerEntity>,
|
||||
private readonly stripeCustomerService: StripeCustomerService,
|
||||
) {}
|
||||
|
||||
async processStripeEvent(data: Stripe.CustomerCreatedEvent.Data) {
|
||||
async processStripeEvent(
|
||||
event:
|
||||
| Stripe.CustomerCreatedEvent
|
||||
| Stripe.PaymentMethodAttachedEvent
|
||||
| Stripe.PaymentMethodDetachedEvent,
|
||||
) {
|
||||
if (event.type === BillingWebhookEvent.CUSTOMER_CREATED) {
|
||||
return this.processCustomerCreated(event.data);
|
||||
}
|
||||
|
||||
if (event.type === BillingWebhookEvent.PAYMENT_METHOD_ATTACHED) {
|
||||
return this.processPaymentMethodAttachedEvent(event.data);
|
||||
}
|
||||
|
||||
if (event.type === BillingWebhookEvent.PAYMENT_METHOD_DETACHED) {
|
||||
return this.processPaymentMethodDetachedEvent(event.data);
|
||||
}
|
||||
}
|
||||
|
||||
private async processCustomerCreated(data: Stripe.CustomerCreatedEvent.Data) {
|
||||
const { id: stripeCustomerId, metadata } = data.object;
|
||||
|
||||
const workspaceId = metadata?.workspaceId;
|
||||
@@ -40,4 +70,82 @@ export class BillingWebhookCustomerService {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async processPaymentMethodAttachedEvent(
|
||||
data: Stripe.PaymentMethodAttachedEvent.Data,
|
||||
) {
|
||||
const stripeCustomerId = this.extractStripeCustomerId(data.object.customer);
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const workspaceId =
|
||||
await this.getWorkspaceIdFromStripeCustomerId(stripeCustomerId);
|
||||
|
||||
if (!workspaceId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
await this.billingCustomerRepository.update(
|
||||
workspaceId,
|
||||
{ stripeCustomerId },
|
||||
{ hasPaymentMethod: true },
|
||||
);
|
||||
}
|
||||
|
||||
private async processPaymentMethodDetachedEvent(
|
||||
data: Stripe.PaymentMethodDetachedEvent.Data,
|
||||
) {
|
||||
const stripeCustomerId = this.extractStripeCustomerId(
|
||||
data.previous_attributes?.customer,
|
||||
);
|
||||
|
||||
if (!isDefined(stripeCustomerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId =
|
||||
await this.getWorkspaceIdFromStripeCustomerId(stripeCustomerId);
|
||||
|
||||
if (!isDefined(workspaceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPaymentMethod =
|
||||
await this.stripeCustomerService.hasPaymentMethod(stripeCustomerId);
|
||||
|
||||
await this.billingCustomerRepository.update(
|
||||
workspaceId,
|
||||
{ stripeCustomerId },
|
||||
{ hasPaymentMethod },
|
||||
);
|
||||
}
|
||||
|
||||
private async getWorkspaceIdFromStripeCustomerId(
|
||||
stripeCustomerId: string,
|
||||
): Promise<string | null> {
|
||||
const billingCustomer =
|
||||
await this.billingCustomerRepositoryUnscoped.findOne({
|
||||
where: { stripeCustomerId },
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
return billingCustomer?.workspaceId ?? null;
|
||||
}
|
||||
|
||||
private extractStripeCustomerId(
|
||||
customer:
|
||||
| string
|
||||
| Stripe.Customer
|
||||
| Stripe.DeletedCustomer
|
||||
| null
|
||||
| undefined,
|
||||
): string | null {
|
||||
if (!customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isString(customer) ? customer : customer.id;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
|
||||
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
|
||||
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
@@ -61,7 +61,7 @@ export class BillingWebhookSubscriptionService {
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly billingUsageCacheService: BillingUsageCacheService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@@ -145,7 +145,7 @@ export class BillingWebhookSubscriptionService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.billingUsageService.flushAvailableCreditsFromCache(workspace.id);
|
||||
await this.billingUsageCacheService.flushAvailableCredits(workspace.id);
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspace.id, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/
|
||||
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
|
||||
import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
|
||||
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
@@ -89,6 +90,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
BillingUpdateSubscriptionPriceCommand,
|
||||
BillingSyncPlansDataCommand,
|
||||
BillingUsageService,
|
||||
BillingUsageCacheService,
|
||||
BillingUsageCapService,
|
||||
BillingPriceService,
|
||||
BillingCreditRolloverService,
|
||||
@@ -107,6 +109,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
BillingPortalWorkspaceService,
|
||||
BillingService,
|
||||
BillingUsageService,
|
||||
BillingUsageCacheService,
|
||||
BillingUsageCapService,
|
||||
BillingCreditRolloverService,
|
||||
ResourceCreditService,
|
||||
|
||||
@@ -71,12 +71,13 @@ export class BillingResolver {
|
||||
)
|
||||
async billingPortalSession(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args() { returnUrlPath }: BillingSessionInput,
|
||||
@Args() { returnUrlPath, forPaymentMethodUpdate }: BillingSessionInput,
|
||||
) {
|
||||
return {
|
||||
url: await this.billingPortalWorkspaceService.computeBillingPortalSessionURLOrThrow(
|
||||
workspace,
|
||||
returnUrlPath,
|
||||
forPaymentMethodUpdate,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
+6
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class BillingSessionInput {
|
||||
@@ -10,4 +10,9 @@ export class BillingSessionInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
returnUrlPath?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
forPaymentMethodUpdate?: boolean;
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { ObjectType } from '@nestjs/graphql';
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
@@ -41,6 +41,11 @@ export class BillingCustomerEntity extends WorkspaceRelatedEntity {
|
||||
@Column({ nullable: false, unique: true })
|
||||
stripeCustomerId: string;
|
||||
|
||||
// Null means unknown (customer created before the flag existed and not synced yet).
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@Column({ nullable: true, type: 'boolean' })
|
||||
hasPaymentMethod: boolean | null;
|
||||
|
||||
@Column({
|
||||
type: 'bigint',
|
||||
nullable: false,
|
||||
|
||||
+2
@@ -5,6 +5,8 @@ export enum BillingWebhookEvent {
|
||||
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
||||
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
||||
CUSTOMER_CREATED = 'customer.created',
|
||||
PAYMENT_METHOD_ATTACHED = 'payment_method.attached',
|
||||
PAYMENT_METHOD_DETACHED = 'payment_method.detached',
|
||||
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
|
||||
CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated',
|
||||
PRODUCT_CREATED = 'product.created',
|
||||
|
||||
+30
-21
@@ -183,6 +183,7 @@ export class BillingPortalWorkspaceService {
|
||||
async computeBillingPortalSessionURLOrThrow(
|
||||
workspace: WorkspaceEntity,
|
||||
returnUrlPath?: string,
|
||||
forPaymentMethodUpdate?: boolean,
|
||||
) {
|
||||
const lastSubscription = await this.billingSubscriptionRepository.findOne(
|
||||
workspace.id,
|
||||
@@ -202,20 +203,17 @@ export class BillingPortalWorkspaceService {
|
||||
throw new Error('Error: missing stripeCustomerId');
|
||||
}
|
||||
|
||||
const frontBaseUrl = this.workspaceDomainsService.buildWorkspaceURL({
|
||||
workspace,
|
||||
});
|
||||
const returnUrl = this.buildReturnUrl(workspace, returnUrlPath);
|
||||
|
||||
if (returnUrlPath) {
|
||||
frontBaseUrl.pathname = returnUrlPath;
|
||||
}
|
||||
const returnUrl = frontBaseUrl.toString();
|
||||
|
||||
const session =
|
||||
await this.stripeBillingPortalService.createBillingPortalSession(
|
||||
stripeCustomerId,
|
||||
returnUrl,
|
||||
);
|
||||
const session = forPaymentMethodUpdate
|
||||
? await this.stripeBillingPortalService.createBillingPortalSessionForPaymentMethodUpdate(
|
||||
stripeCustomerId,
|
||||
returnUrl,
|
||||
)
|
||||
: await this.stripeBillingPortalService.createBillingPortalSession(
|
||||
stripeCustomerId,
|
||||
returnUrl,
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
session.url,
|
||||
@@ -233,14 +231,7 @@ export class BillingPortalWorkspaceService {
|
||||
stripeCustomerId: string,
|
||||
returnUrlPath?: string,
|
||||
) {
|
||||
const frontBaseUrl = this.workspaceDomainsService.buildWorkspaceURL({
|
||||
workspace,
|
||||
});
|
||||
|
||||
if (returnUrlPath) {
|
||||
frontBaseUrl.pathname = returnUrlPath;
|
||||
}
|
||||
const returnUrl = frontBaseUrl.toString();
|
||||
const returnUrl = this.buildReturnUrl(workspace, returnUrlPath);
|
||||
|
||||
const session =
|
||||
await this.stripeBillingPortalService.createBillingPortalSessionForPaymentMethodUpdate(
|
||||
@@ -259,6 +250,24 @@ export class BillingPortalWorkspaceService {
|
||||
return session.url;
|
||||
}
|
||||
|
||||
private buildReturnUrl(workspace: WorkspaceEntity, returnUrlPath?: string) {
|
||||
const frontBaseUrl = this.workspaceDomainsService.buildWorkspaceURL({
|
||||
workspace,
|
||||
});
|
||||
|
||||
if (!isDefined(returnUrlPath)) {
|
||||
return frontBaseUrl.toString();
|
||||
}
|
||||
|
||||
const resolvedUrl = new URL(returnUrlPath, frontBaseUrl);
|
||||
|
||||
if (resolvedUrl.origin !== frontBaseUrl.origin) {
|
||||
return frontBaseUrl.toString();
|
||||
}
|
||||
|
||||
return resolvedUrl.toString();
|
||||
}
|
||||
|
||||
private getDefaultResourceCreditPrice(
|
||||
billingPricesPerPlan: BillingGetPricesPerPlanResult,
|
||||
) {
|
||||
|
||||
+21
@@ -28,6 +28,7 @@ import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/bil
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
|
||||
import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
|
||||
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
|
||||
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
@@ -37,6 +38,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
@Injectable()
|
||||
export class BillingSubscriptionService {
|
||||
protected readonly logger = new Logger(BillingSubscriptionService.name);
|
||||
@@ -62,12 +64,20 @@ export class BillingSubscriptionService {
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly billingUsageCacheService: BillingUsageCacheService,
|
||||
) {}
|
||||
|
||||
async getBillingSubscriptions(workspaceId: string) {
|
||||
return await this.billingSubscriptionRepository.find(workspaceId);
|
||||
}
|
||||
|
||||
async getBillingCustomer(
|
||||
workspaceId: string,
|
||||
): Promise<BillingCustomerEntity | null> {
|
||||
return await this.billingCustomerRepository.findOneBy(workspaceId, {});
|
||||
}
|
||||
|
||||
async getCurrentBillingSubscription(criteria: {
|
||||
workspaceId?: string;
|
||||
stripeCustomerId?: string;
|
||||
@@ -267,11 +277,22 @@ export class BillingSubscriptionService {
|
||||
},
|
||||
);
|
||||
|
||||
await this.syncSubscriptionToDatabase(
|
||||
billingSubscription.workspaceId,
|
||||
updatedSubscription.id,
|
||||
);
|
||||
|
||||
await this.billingSubscriptionItemRepository.update(
|
||||
{ stripeSubscriptionId: updatedSubscription.id },
|
||||
{ hasReachedCurrentPeriodCap: false },
|
||||
);
|
||||
|
||||
await this.billingUsageCacheService.flushAvailableCredits(workspace.id);
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspace.id, [
|
||||
'currentBillingSubscription',
|
||||
]);
|
||||
|
||||
return {
|
||||
status: getSubscriptionStatus(updatedSubscription.status),
|
||||
hasPaymentMethod: true,
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modules/billing/utils/build-billing-usage-available-credits-cache-key.util';
|
||||
import { buildBillingUsageAvailableCreditsCachePattern } from 'src/engine/core-modules/billing/utils/build-billing-usage-available-credits-cache-pattern.util';
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
|
||||
@Injectable()
|
||||
export class BillingUsageCacheService {
|
||||
constructor(
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineBillingUsage)
|
||||
private readonly billingUsageCacheStorage: CacheStorageService,
|
||||
) {}
|
||||
|
||||
async getAvailableCredits(
|
||||
workspaceId: string,
|
||||
periodStart: Date | string,
|
||||
): Promise<number | undefined> {
|
||||
return this.billingUsageCacheStorage.get<number>(
|
||||
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
|
||||
);
|
||||
}
|
||||
|
||||
async warmAvailableCredits(
|
||||
workspaceId: string,
|
||||
periodStart: Date | string,
|
||||
periodEnd: Date | string,
|
||||
availableCredits: number,
|
||||
): Promise<void> {
|
||||
const ttlMs = Math.max(new Date(periodEnd).getTime() - Date.now(), 0);
|
||||
|
||||
await this.billingUsageCacheStorage.set(
|
||||
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
|
||||
availableCredits,
|
||||
ttlMs,
|
||||
);
|
||||
}
|
||||
|
||||
async decrementAvailableCredits(
|
||||
workspaceId: string,
|
||||
periodStart: Date | string,
|
||||
usedCredits: number,
|
||||
): Promise<number> {
|
||||
return this.billingUsageCacheStorage.incrBy(
|
||||
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
|
||||
-usedCredits,
|
||||
);
|
||||
}
|
||||
|
||||
async invalidateAvailableCredits(
|
||||
workspaceId: string,
|
||||
periodStart: Date | string,
|
||||
): Promise<void> {
|
||||
await this.billingUsageCacheStorage.del(
|
||||
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
|
||||
);
|
||||
}
|
||||
|
||||
async flushAvailableCredits(workspaceId: string): Promise<void> {
|
||||
await this.billingUsageCacheStorage.flushByPattern(
|
||||
buildBillingUsageAvailableCreditsCachePattern(workspaceId),
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-58
@@ -21,11 +21,8 @@ import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
|
||||
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
|
||||
import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modules/billing/utils/build-billing-usage-available-credits-cache-key.util';
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
@@ -45,8 +42,7 @@ export class BillingUsageService {
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineBillingUsage)
|
||||
private readonly billingUsageCacheStorage: CacheStorageService,
|
||||
private readonly billingUsageCacheService: BillingUsageCacheService,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
@@ -167,36 +163,6 @@ export class BillingUsageService {
|
||||
};
|
||||
}
|
||||
|
||||
async flushAvailableCreditsFromCache(workspaceId: string): Promise<void> {
|
||||
await this.billingUsageCacheStorage.flushByPattern(
|
||||
`available-credits:${workspaceId}:*`,
|
||||
);
|
||||
}
|
||||
|
||||
private async warmAvailableCreditsInCache(
|
||||
workspaceId: string,
|
||||
periodStart: Date | string,
|
||||
periodEnd: Date | string,
|
||||
availableCredits: number,
|
||||
): Promise<void> {
|
||||
const ttlMs = Math.max(new Date(periodEnd).getTime() - Date.now(), 0);
|
||||
|
||||
await this.billingUsageCacheStorage.set(
|
||||
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
|
||||
availableCredits,
|
||||
ttlMs,
|
||||
);
|
||||
}
|
||||
|
||||
private async getAvailableCreditsFromCache(
|
||||
workspaceId: string,
|
||||
periodStart: Date | string,
|
||||
): Promise<number | undefined> {
|
||||
return this.billingUsageCacheStorage.get<number>(
|
||||
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
|
||||
);
|
||||
}
|
||||
|
||||
private async getAvailableCreditsFromClickHouse({
|
||||
workspaceId,
|
||||
currentPeriodStart,
|
||||
@@ -300,10 +266,11 @@ export class BillingUsageService {
|
||||
|
||||
const { currentPeriodStart, currentPeriodEnd } = currentBillingSubscription;
|
||||
|
||||
const cachedAvailableCredits = await this.getAvailableCreditsFromCache(
|
||||
workspaceId,
|
||||
currentPeriodStart,
|
||||
);
|
||||
const cachedAvailableCredits =
|
||||
await this.billingUsageCacheService.getAvailableCredits(
|
||||
workspaceId,
|
||||
currentPeriodStart,
|
||||
);
|
||||
|
||||
const availableCredits = isDefined(cachedAvailableCredits)
|
||||
? cachedAvailableCredits
|
||||
@@ -313,7 +280,7 @@ export class BillingUsageService {
|
||||
});
|
||||
|
||||
if (!isDefined(cachedAvailableCredits)) {
|
||||
await this.warmAvailableCreditsInCache(
|
||||
await this.billingUsageCacheService.warmAvailableCredits(
|
||||
workspaceId,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
@@ -322,12 +289,10 @@ export class BillingUsageService {
|
||||
}
|
||||
|
||||
const decrementedAvailableCredits =
|
||||
await this.billingUsageCacheStorage.incrBy(
|
||||
buildBillingUsageAvailableCreditsCacheKey(
|
||||
workspaceId,
|
||||
currentPeriodStart,
|
||||
),
|
||||
-usedCredits,
|
||||
await this.billingUsageCacheService.decrementAvailableCredits(
|
||||
workspaceId,
|
||||
currentPeriodStart,
|
||||
usedCredits,
|
||||
);
|
||||
|
||||
const hasJustReachedCap =
|
||||
@@ -343,15 +308,6 @@ export class BillingUsageService {
|
||||
return decrementedAvailableCredits;
|
||||
}
|
||||
|
||||
async invalidateAvailableCreditsInCache(
|
||||
workspaceId: string,
|
||||
periodStart: Date,
|
||||
): Promise<void> {
|
||||
await this.billingUsageCacheStorage.del(
|
||||
buildBillingUsageAvailableCreditsCacheKey(workspaceId, periodStart),
|
||||
);
|
||||
}
|
||||
|
||||
async hasAvailableCredits(workspaceId: string): Promise<boolean> {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
return true;
|
||||
@@ -380,7 +336,7 @@ export class BillingUsageService {
|
||||
|
||||
const subscription = currentBillingSubscription;
|
||||
|
||||
const cached = await this.getAvailableCreditsFromCache(
|
||||
const cached = await this.billingUsageCacheService.getAvailableCredits(
|
||||
subscription.workspaceId,
|
||||
subscription.currentPeriodStart,
|
||||
);
|
||||
@@ -394,7 +350,7 @@ export class BillingUsageService {
|
||||
currentPeriodStart: subscription.currentPeriodStart,
|
||||
});
|
||||
|
||||
await this.warmAvailableCreditsInCache(
|
||||
await this.billingUsageCacheService.warmAvailableCredits(
|
||||
subscription.workspaceId,
|
||||
subscription.currentPeriodStart,
|
||||
subscription.currentPeriodEnd,
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ export class StripeCustomerService {
|
||||
|
||||
await this.billingCustomerRepository.save(workspaceId, {
|
||||
stripeCustomerId: customer.id,
|
||||
hasPaymentMethod: false,
|
||||
});
|
||||
|
||||
return customer;
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const buildBillingUsageAvailableCreditsCachePattern = (
|
||||
workspaceId: string,
|
||||
): string => {
|
||||
return `available-credits:${workspaceId}:*`;
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/applica
|
||||
import { fromFlatApplicationToApplicationDto } from 'src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { BillingEntitlementDTO } from 'src/engine/core-modules/billing/dtos/billing-entitlement.dto';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
|
||||
@@ -282,6 +283,17 @@ export class WorkspaceResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => BillingCustomerEntity, { nullable: true })
|
||||
async billingCustomer(
|
||||
@Parent() workspace: WorkspaceEntity,
|
||||
): Promise<BillingCustomerEntity | null> {
|
||||
if (!this.twentyConfigService.isBillingEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.billingSubscriptionService.getBillingCustomer(workspace.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => Number)
|
||||
async workspaceMembersCount(
|
||||
@Parent() workspace: WorkspaceEntity,
|
||||
|
||||
+6
@@ -1,5 +1,7 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { BillingException } from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { billingGraphqlApiExceptionHandler } from 'src/engine/core-modules/billing/utils/billing-graphql-api-exception-handler.util';
|
||||
import {
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
@@ -13,6 +15,10 @@ import {
|
||||
} from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
|
||||
export const aiGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof BillingException) {
|
||||
return billingGraphqlApiExceptionHandler(error);
|
||||
}
|
||||
|
||||
if (error instanceof AiException) {
|
||||
switch (error.code) {
|
||||
case AiExceptionCode.AGENT_NOT_FOUND:
|
||||
|
||||
Reference in New Issue
Block a user