feat(pricing/ai): improve billing metered pricing + add pricing on ai chat (#14092)

## TODO:

- [x] display "yearly" or "monthly" wording everywhere it's needed
- [ ] Add button with "downgrade" or "upgrade" to save the change of
credits price + modal to validate
- [x] Add renewal date 
- [ ] Implement
https://docs.stripe.com/billing/subscriptions/subscription-schedules for
`switchFromYearlyToMonthly` and decrease number of credits
This commit is contained in:
Antoine Moreaux
2025-08-29 18:23:07 +02:00
committed by GitHub
parent 7df9094939
commit 1c4568c8b1
45 changed files with 2048 additions and 327 deletions
File diff suppressed because one or more lines are too long
+19 -4
View File
@@ -249,14 +249,12 @@ export type BillingEndTrialPeriodOutput = {
export type BillingMeteredProductUsageOutput = {
__typename?: 'BillingMeteredProductUsageOutput';
freeTierQuantity: Scalars['Float'];
freeTrialQuantity: Scalars['Float'];
grantedCredits: Scalars['Float'];
periodEnd: Scalars['DateTime'];
periodStart: Scalars['DateTime'];
productKey: BillingProductKey;
totalCostCents: Scalars['Float'];
unitPriceCents: Scalars['Float'];
usageQuantity: Scalars['Float'];
usedCredits: Scalars['Float'];
};
/** The different billing plans available */
@@ -290,6 +288,14 @@ export type BillingPriceMeteredDto = {
tiersMode?: Maybe<BillingPriceTiersMode>;
};
export type BillingPriceOutput = {
__typename?: 'BillingPriceOutput';
amount: Scalars['Float'];
nickname: Scalars['String'];
recurringInterval: SubscriptionInterval;
stripePriceId: Scalars['String'];
};
export type BillingPriceTierDto = {
__typename?: 'BillingPriceTierDTO';
flatAmount?: Maybe<Scalars['Float']>;
@@ -335,6 +341,7 @@ export type BillingSessionOutput = {
export type BillingSubscription = {
__typename?: 'BillingSubscription';
billingSubscriptionItems?: Maybe<Array<BillingSubscriptionItem>>;
currentPeriodEnd?: Maybe<Scalars['DateTime']>;
id: Scalars['UUID'];
interval?: Maybe<SubscriptionInterval>;
metadata: Scalars['JSON'];
@@ -347,6 +354,7 @@ export type BillingSubscriptionItem = {
hasReachedCurrentPeriodCap: Scalars['Boolean'];
id: Scalars['UUID'];
quantity?: Maybe<Scalars['Float']>;
stripePriceId?: Maybe<Scalars['String']>;
};
export type BillingTrialPeriodDto = {
@@ -1401,6 +1409,7 @@ export type Mutation = {
updateOneRole: Role;
updateOneServerlessFunction: ServerlessFunction;
updatePasswordViaResetToken: InvalidatePassword;
updateSubscriptionItemPrice: BillingUpdateOutput;
updateWebhook?: Maybe<Webhook>;
updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionPositions: Scalars['Boolean'];
@@ -1975,6 +1984,11 @@ export type MutationUpdatePasswordViaResetTokenArgs = {
};
export type MutationUpdateSubscriptionItemPriceArgs = {
priceId: Scalars['String'];
};
export type MutationUpdateWebhookArgs = {
input: UpdateWebhookDto;
};
@@ -2360,6 +2374,7 @@ export type Query = {
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
index: Index;
indexMetadatas: IndexConnection;
listAvailableMeteredBillingPrices: Array<BillingPriceOutput>;
object: Object;
objects: ObjectConnection;
plans: Array<BillingPlanOutput>;
@@ -0,0 +1,102 @@
import { SettingsBillingLabelValueItem } from '@/billing/components/SettingsBillingLabelValueItem';
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { ProgressBar } from 'twenty-ui/feedback';
import { Section } from 'twenty-ui/layout';
import { BACKGROUND_LIGHT, COLOR } from 'twenty-ui/theme';
import { SubscriptionStatus } from '~/generated/graphql';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
import { useListAvailableMeteredBillingPricesQuery } from '~/generated-metadata/graphql';
import { MeteredPriceSelector } from '@/billing/components/internal/MeteredPriceSelector';
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import {
getIntervalLabel,
isMonthlyPlan,
} from '@/billing/utils/subscriptionFlags';
const StyledLineSeparator = styled.div`
width: 100%;
height: 1px;
background-color: ${({ theme }) => theme.background.tertiary};
`;
export const SettingsBillingCreditsSection = ({
currentWorkspace,
}: {
currentWorkspace: CurrentWorkspace;
}) => {
const subscriptionStatus = useSubscriptionStatus();
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const { usedCredits, grantedCredits, unitPriceCents } =
useGetWorkflowNodeExecutionUsage();
const { data: meteredBillingPrices } =
useListAvailableMeteredBillingPricesQuery();
const progressBarValue = (usedCredits / grantedCredits) * 100;
const intervalLabel = getIntervalLabel(isMonthlyPlan(currentWorkspace));
return (
<>
<Section>
<H2Title
title={t`Credit Usage`}
description={t`Track your ${intervalLabel} workflow credit consumption.`}
/>
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`Credits Used`}
value={`${formatNumber(usedCredits)}/${formatAmount(grantedCredits)}`}
/>
<ProgressBar
value={progressBarValue}
barColor={progressBarValue > 100 ? COLOR.red40 : COLOR.blue}
backgroundColor={BACKGROUND_LIGHT.tertiary}
withBorderRadius={true}
/>
<StyledLineSeparator />
{!isTrialing && (
<SettingsBillingLabelValueItem
label={t`Extra Credits Used`}
value={`${formatNumber(Math.max(0, usedCredits - grantedCredits))}`}
/>
)}
<SettingsBillingLabelValueItem
label={t`Cost per 1k Extra Credits`}
value={`$${formatNumber((unitPriceCents / 100) * 1000, 2)}`}
/>
{!isTrialing && (
<SettingsBillingLabelValueItem
label={t`Cost`}
isValueInPrimaryColor={true}
value={`$${formatNumber(((usedCredits - grantedCredits) * unitPriceCents) / 100, 2)}`}
/>
)}
</SubscriptionInfoContainer>
</Section>
<Section>
{meteredBillingPrices?.listAvailableMeteredBillingPrices && (
<MeteredPriceSelector
billingSubscriptionItems={
currentWorkspace.currentBillingSubscription
?.billingSubscriptionItems ?? []
}
meteredBillingPrices={
meteredBillingPrices.listAvailableMeteredBillingPrices
}
isTrialing={isTrialing}
/>
)}
</Section>
</>
);
};
@@ -1,86 +0,0 @@
import { SettingsBillingLabelValueItem } from '@/billing/components/SettingsBillingLabelValueItem';
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { ProgressBar } from 'twenty-ui/feedback';
import { Section } from 'twenty-ui/layout';
import { BACKGROUND_LIGHT, COLOR } from 'twenty-ui/theme';
import { SubscriptionStatus } from '~/generated/graphql';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
const StyledLineSeparator = styled.div`
width: 100%;
height: 1px;
background-color: ${({ theme }) => theme.background.tertiary};
`;
export const SettingsBillingMonthlyCreditsSection = () => {
const subscriptionStatus = useSubscriptionStatus();
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const {
freeUsageQuantity,
includedFreeQuantity,
paidUsageQuantity,
unitPriceCents,
totalCostCents,
} = useGetWorkflowNodeExecutionUsage();
const isFreeCreditProgressBarCompleted =
freeUsageQuantity === includedFreeQuantity;
const progressBarValue = (freeUsageQuantity / includedFreeQuantity) * 100;
const formattedFreeUsageQuantity = isFreeCreditProgressBarCompleted
? formatAmount(freeUsageQuantity)
: formatNumber(freeUsageQuantity);
return (
<Section>
<H2Title
title={t`Monthly Credits`}
description={t`Track your monthly workflow credit consumption.`}
/>
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`Free Credits Used`}
value={`${formattedFreeUsageQuantity}/${formatAmount(includedFreeQuantity)}`}
/>
<ProgressBar
value={progressBarValue}
barColor={
isFreeCreditProgressBarCompleted
? BACKGROUND_LIGHT.quaternary
: COLOR.blue
}
backgroundColor={BACKGROUND_LIGHT.tertiary}
withBorderRadius={true}
/>
<StyledLineSeparator />
{!isTrialing && (
<SettingsBillingLabelValueItem
label={t`Extra Credits Used`}
value={`${formatNumber(paidUsageQuantity)}`}
/>
)}
<SettingsBillingLabelValueItem
label={t`Cost per 1k Extra Credits`}
value={`$${formatNumber((unitPriceCents / 100) * 1000, 2)}`}
/>
{!isTrialing && (
<SettingsBillingLabelValueItem
label={t`Cost`}
isValueInPrimaryColor={true}
value={`$${formatNumber(totalCostCents / 100, 2)}`}
/>
)}
</SubscriptionInfoContainer>
</Section>
);
};
@@ -3,6 +3,13 @@ import { SubscriptionInfoRowContainer } from '@/billing/components/SubscriptionI
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { formatMonthlyPrices } from '@/billing/utils/formatMonthlyPrices';
import {
isMonthlyPlan as isMonthlyPlanFn,
isYearlyPlan as isYearlyPlanFn,
isProPlan as isProPlanFn,
isEnterprisePlan as isEnterprisePlanFn,
getIntervalLabel,
} from '@/billing/utils/subscriptionFlags';
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';
@@ -10,14 +17,16 @@ import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import {
H2Title,
IconArrowUp,
IconCalendarEvent,
IconCircleX,
IconTag,
IconUsers,
IconCalendarRepeat,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -25,17 +34,23 @@ import {
BillingPlanKey,
type BillingPlanOutput,
BillingProductKey,
PermissionFlagType,
SubscriptionInterval,
SubscriptionStatus,
useBillingBaseProductPricesQuery,
useSwitchSubscriptionToEnterprisePlanMutation,
useSwitchSubscriptionToYearlyIntervalMutation,
} from '~/generated-metadata/graphql';
import { beautifyExactDate } from '~/utils/date-utils';
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
const SWITCH_BILLING_INTERVAL_MODAL_ID = 'switch-billing-interval-modal';
const SWITCH_BILLING_PLAN_MODAL_ID = 'switch-billing-plan-modal';
const END_TRIAL_PERIOD_MODAL_ID = 'end-trial-period-modal';
const StyledSwitchButtonContainer = styled.div`
align-items: center;
display: flex;
@@ -64,36 +79,38 @@ export const SettingsBillingSubscriptionInfo = () => {
currentWorkspaceState,
);
const isMonthlyPlan =
currentWorkspace?.currentBillingSubscription?.interval ===
SubscriptionInterval.Month;
const isMonthlyPlan = isMonthlyPlanFn(currentWorkspace);
const isYearlyPlan =
currentWorkspace?.currentBillingSubscription?.interval ===
SubscriptionInterval.Year;
const isYearlyPlan = isYearlyPlanFn(currentWorkspace);
const isProPlan =
currentWorkspace?.currentBillingSubscription?.metadata['plan'] ===
BillingPlanKey.PRO;
const isProPlan = isProPlanFn(currentWorkspace);
const isEnterprisePlan =
currentWorkspace?.currentBillingSubscription?.metadata['plan'] ===
BillingPlanKey.ENTERPRISE;
const isEnterprisePlan = isEnterprisePlanFn(currentWorkspace);
const isTrialPeriod = subscriptionStatus === SubscriptionStatus.Trialing;
const canSwitchSubscription =
subscriptionStatus !== SubscriptionStatus.PastDue;
const planTag = isProPlan ? (
<Tag color={'sky'} text={t`Pro`} />
) : isEnterprisePlan ? (
<Tag color={'purple'} text={t`Organization`} />
const { endTrialPeriod, isLoading: isEndTrialPeriodLoading } =
useEndSubscriptionTrialPeriod();
const planDescriptor = isProPlan
? { color: 'sky' as const, label: t`Pro` }
: isEnterprisePlan
? { color: 'purple' as const, label: t`Organization` }
: undefined;
const planTag = planDescriptor ? (
<>
<Tag color={planDescriptor.color} text={planDescriptor.label} />
{isTrialPeriod && <Tag color="blue" text={t`Trial`} />}
</>
) : undefined;
const intervalLabel = isMonthlyPlan
? t`Monthly`
: isYearlyPlan
? t`Yearly`
: undefined;
const intervalLabel = capitalize(getIntervalLabel(isMonthlyPlan, true));
const { [PermissionFlagType.WORKSPACE]: hasPermissionToEndTrialPeriod } =
usePermissionFlagMap();
const seats =
currentWorkspace?.currentBillingSubscription?.billingSubscriptionItems?.find(
@@ -106,6 +123,9 @@ export const SettingsBillingSubscriptionInfo = () => {
const formattedPrices = formatMonthlyPrices(baseProductPrices);
const renewDate =
currentWorkspace?.currentBillingSubscription?.currentPeriodEnd;
const yearlyPrice =
formattedPrices?.[
currentWorkspace?.currentBillingSubscription?.metadata[
@@ -183,6 +203,13 @@ export const SettingsBillingSubscriptionInfo = () => {
Icon={IconCalendarEvent}
value={intervalLabel}
/>
{renewDate && (
<SubscriptionInfoRowContainer
label={t`Renewal date`}
Icon={IconCalendarRepeat}
value={beautifyExactDate(renewDate)}
/>
)}
<SubscriptionInfoRowContainer
label={t`Seats`}
Icon={IconUsers}
@@ -208,6 +235,15 @@ export const SettingsBillingSubscriptionInfo = () => {
disabled={!canSwitchSubscription}
/>
)}
{isTrialPeriod && hasPermissionToEndTrialPeriod && (
<Button
Icon={IconCircleX}
title={t`Subscribe Now`}
variant="secondary"
onClick={() => openModal(END_TRIAL_PERIOD_MODAL_ID)}
disabled={isEndTrialPeriodLoading}
/>
)}
</StyledSwitchButtonContainer>
<ConfirmationModal
modalId={SWITCH_BILLING_INTERVAL_MODAL_ID}
@@ -229,6 +265,15 @@ export const SettingsBillingSubscriptionInfo = () => {
confirmButtonText={t`Confirm`}
confirmButtonAccent={'blue'}
/>
<ConfirmationModal
modalId={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'}
loading={isEndTrialPeriodLoading}
/>
</Section>
);
};
@@ -0,0 +1,100 @@
import { t } from '@lingui/core/macro';
import { useMutation } from '@apollo/client';
import { useMemo, useState } from 'react';
import { H2Title } from 'twenty-ui/display';
import { UPDATE_SUBSCRIPTION_ITEM_PRICE } from '@/billing/graphql/mutations/updateSubscriptionItemPrice';
import { findMeteredPriceInCurrentWorkspaceSubscriptions } from '@/billing/utils/findPriceInCurrentWorkspaceSubscriptions';
import { Select } from '@/ui/input/components/Select';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { formatNumber } from '~/utils/format/number';
import {
type BillingPriceOutput,
type BillingSubscriptionItem,
SubscriptionInterval,
} from '~/generated/graphql';
import { findOrThrow } from '~/utils/array/findOrThrow';
import { getIntervalLabel } from '@/billing/utils/subscriptionFlags';
const compareByAmountAsc = (a: BillingPriceOutput, b: BillingPriceOutput) =>
a.amount - b.amount;
const toOption = (meteredBillingPrice: BillingPriceOutput) => {
const nickname = meteredBillingPrice.nickname;
const price = formatNumber(meteredBillingPrice.amount / 100, 2);
return {
label: t`${nickname} - ${price}$`,
value: meteredBillingPrice.stripePriceId,
};
};
export const MeteredPriceSelector = ({
meteredBillingPrices,
billingSubscriptionItems,
isTrialing = false,
}: {
meteredBillingPrices: Array<BillingPriceOutput>;
billingSubscriptionItems: Array<BillingSubscriptionItem>;
isTrialing?: boolean;
}) => {
const [currentMeteredBillingPrice, setCurrentMeteredBillingPrice] = useState(
findMeteredPriceInCurrentWorkspaceSubscriptions(
billingSubscriptionItems,
meteredBillingPrices,
),
);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [updateSubscriptionItemPrice, { loading: isUpdating }] = useMutation(
UPDATE_SUBSCRIPTION_ITEM_PRICE,
);
const options = useMemo(
() => [...meteredBillingPrices].sort(compareByAmountAsc).map(toOption),
[meteredBillingPrices],
);
const handleChange = async (priceId: string) => {
try {
await updateSubscriptionItemPrice({
variables: { priceId },
});
enqueueSuccessSnackBar({ message: t`Price updated.` });
setCurrentMeteredBillingPrice(
findOrThrow(
meteredBillingPrices,
({ stripePriceId }) => stripePriceId === priceId,
),
);
} catch {
enqueueErrorSnackBar({
message: t`Failed to update price.`,
});
}
};
const recurringInterval = getIntervalLabel(
currentMeteredBillingPrice?.recurringInterval ===
SubscriptionInterval.Month,
);
return (
<>
<H2Title
title={t`Credit Plan`}
description={t`Number of new credits allocated every ${recurringInterval}`}
/>
<Select
dropdownId={'settings-billing-metered-price'}
options={options}
value={currentMeteredBillingPrice?.stripePriceId}
onChange={handleChange}
disabled={isUpdating || isTrialing}
description={
isTrialing ? t`Please start your subscription first` : undefined
}
/>
</>
);
};
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPDATE_SUBSCRIPTION_ITEM_PRICE = gql`
mutation UpdateSubscriptionItemPrice($priceId: String!) {
updateSubscriptionItemPrice(priceId: $priceId) {
success
}
}
`;
@@ -4,11 +4,9 @@ export const GET_METERED_PRODUCTS_USAGE = gql`
query GetMeteredProductsUsage {
getMeteredProductsUsage {
productKey
usageQuantity
freeTierQuantity
freeTrialQuantity
usedCredits
grantedCredits
unitPriceCents
totalCostCents
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const LIST_AVAILABLE_METERED_BILLING_PRICES = gql`
query listAvailableMeteredBillingPrices {
listAvailableMeteredBillingPrices {
nickname
amount
stripePriceId
recurringInterval
}
}
`;
@@ -1,13 +1,9 @@
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import {
BillingProductKey,
SubscriptionStatus,
useGetMeteredProductsUsageQuery,
} from '~/generated-metadata/graphql';
export const useGetWorkflowNodeExecutionUsage = () => {
const subscriptionStatus = useSubscriptionStatus();
const { data, loading } = useGetMeteredProductsUsageQuery();
const workflowUsage = data?.getMeteredProductsUsage.find(
@@ -17,32 +13,15 @@ export const useGetWorkflowNodeExecutionUsage = () => {
if (loading === true || !workflowUsage) {
return {
usageQuantity: 0,
freeUsageQuantity: 0,
includedFreeQuantity: 10000,
paidUsageQuantity: 0,
usedCredits: 0,
grantedCredits: 10000,
unitPriceCents: 0,
totalCostCents: 0,
};
}
const includedFreeQuantity =
subscriptionStatus === SubscriptionStatus.Trialing
? workflowUsage.freeTrialQuantity
: workflowUsage.freeTierQuantity;
return {
usageQuantity: workflowUsage.usageQuantity,
freeUsageQuantity:
workflowUsage.usageQuantity > includedFreeQuantity
? includedFreeQuantity
: workflowUsage.usageQuantity,
includedFreeQuantity,
paidUsageQuantity:
workflowUsage.usageQuantity > includedFreeQuantity
? workflowUsage.usageQuantity - includedFreeQuantity
: 0,
usedCredits: workflowUsage.usedCredits,
grantedCredits: workflowUsage.grantedCredits,
unitPriceCents: workflowUsage.unitPriceCents,
totalCostCents: workflowUsage.totalCostCents,
};
};
@@ -0,0 +1,16 @@
import {
type BillingPriceOutput,
type BillingSubscriptionItem,
} from '~/generated/graphql';
import { findOrThrow } from '~/utils/array/findOrThrow';
export const findMeteredPriceInCurrentWorkspaceSubscriptions = (
subscriptionItems: Array<BillingSubscriptionItem>,
meteredBillingPrices: Array<BillingPriceOutput>,
): BillingPriceOutput =>
findOrThrow(meteredBillingPrices, (meteredBillingPrice) =>
subscriptionItems.some(
(subscriptionItem) =>
subscriptionItem.stripePriceId === meteredBillingPrice.stripePriceId,
),
);
@@ -0,0 +1,39 @@
import { t } from '@lingui/core/macro';
import type { CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { BillingPlanKey, SubscriptionInterval } from '~/generated/graphql';
export const isMonthlyPlan = (
currentWorkspace: CurrentWorkspace | null | undefined,
): boolean =>
currentWorkspace?.currentBillingSubscription?.interval ===
SubscriptionInterval.Month;
export const isYearlyPlan = (
currentWorkspace: CurrentWorkspace | null | undefined,
): boolean =>
currentWorkspace?.currentBillingSubscription?.interval ===
SubscriptionInterval.Year;
export const isProPlan = (
currentWorkspace: CurrentWorkspace | null | undefined,
): boolean =>
currentWorkspace?.currentBillingSubscription?.metadata?.['plan'] ===
BillingPlanKey.PRO;
export const isEnterprisePlan = (
currentWorkspace: CurrentWorkspace | null | undefined,
): boolean =>
currentWorkspace?.currentBillingSubscription?.metadata?.['plan'] ===
BillingPlanKey.ENTERPRISE;
export const getIntervalLabel = (
isMonthly: boolean,
asAdjective: boolean = false,
): string =>
isMonthly && asAdjective
? t`monthly`
: asAdjective
? t`yearly`
: isMonthly
? t`month`
: t`year`;
@@ -69,10 +69,12 @@ export const USER_QUERY_FRAGMENT = gql`
status
interval
metadata
currentPeriodEnd
billingSubscriptionItems {
id
hasReachedCurrentPeriodCap
quantity
stripePriceId
billingProduct {
name
description
@@ -2,7 +2,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsBillingMonthlyCreditsSection } from '@/billing/components/SettingsBillingMonthlyCreditsSection';
import { SettingsBillingCreditsSection } from '@/billing/components/SettingsBillingCreditsSection';
import { SettingsBillingSubscriptionInfo } from '@/billing/components/SettingsBillingSubscriptionInfo';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
@@ -67,8 +67,8 @@ export const SettingsBilling = () => {
{hasNotCanceledCurrentSubscription && (
<SettingsBillingSubscriptionInfo />
)}
{hasNotCanceledCurrentSubscription && (
<SettingsBillingMonthlyCreditsSection />
{hasNotCanceledCurrentSubscription && currentWorkspace && (
<SettingsBillingCreditsSection currentWorkspace={currentWorkspace} />
)}
<Section>
<H2Title
@@ -0,0 +1,42 @@
import { findOrThrow } from '~/utils/array/findOrThrow';
describe('findOrThrow', () => {
it('should return the element that matches the predicate', () => {
const array = [1, 2, 3, 4];
const predicate = (num: number) => num === 3;
const result = findOrThrow(array, predicate);
expect(result).toBe(3);
});
it('should throw an error if no element matches the predicate', () => {
const array = [1, 2, 3, 4];
const predicate = (num: number) => num === 5;
expect(() => findOrThrow(array, predicate)).toThrow('Element not found');
});
it('should work with non-numeric data types', () => {
const array = ['apple', 'banana', 'cherry'];
const predicate = (fruit: string) => fruit === 'banana';
const result = findOrThrow(array, predicate);
expect(result).toBe('banana');
});
it('should throw an error if the array is empty', () => {
const array: number[] = [];
const predicate = (num: number) => num === 1;
expect(() => findOrThrow(array, predicate)).toThrow('Element not found');
});
it('should throw an error if predicate is never satisfied', () => {
const array = [1, 2, 3];
const predicate = (num: number) => num > 10;
expect(() => findOrThrow(array, predicate)).toThrow('Element not found');
});
});
@@ -0,0 +1,14 @@
import { isDefined } from 'twenty-shared/utils';
export const findOrThrow = <T>(
array: T[],
predicate: (value: T) => boolean,
): T => {
const result = array.find(predicate);
if (!isDefined(result)) {
throw new Error('Element not found');
}
return result;
};
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiController } from './ai.controller';
@@ -9,16 +10,22 @@ describe('AiController', () => {
let controller: AiController;
let aiService: jest.Mocked<AiService>;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let aiBillingService: jest.Mocked<AIBillingService>;
beforeEach(async () => {
const mockAiService = {
streamText: jest.fn(),
getModel: jest.fn(),
};
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn().mockResolvedValue(true),
};
const mockAIBillingService = {
calculateAndBillUsage: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AiController],
providers: [
@@ -30,12 +37,17 @@ describe('AiController', () => {
provide: FeatureFlagService,
useValue: mockFeatureFlagService,
},
{
provide: AIBillingService,
useValue: mockAIBillingService,
},
],
}).compile();
controller = module.get<AiController>(AiController);
aiService = module.get(AiService);
featureFlagService = module.get(FeatureFlagService);
aiBillingService = module.get(AIBillingService);
});
it('should be defined', () => {
@@ -45,7 +57,7 @@ describe('AiController', () => {
describe('chat', () => {
const mockWorkspace = { id: 'workspace-1' } as any;
it('should handle valid chat request', async () => {
it('should handle valid chat request and bill usage', async () => {
const mockRequest = {
messages: [{ role: 'user' as const, content: 'Hello' }],
temperature: 0.7,
@@ -58,22 +70,44 @@ describe('AiController', () => {
end: jest.fn(),
} as any;
const mockModel = { modelId: 'gpt-4o' } as any;
aiService.getModel.mockReturnValue(mockModel);
const mockUsage = {
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
};
const mockStreamTextResult = {
usage: Promise.resolve(mockUsage),
pipeDataStreamToResponse: jest.fn(),
};
aiService.streamText.mockReturnValue(mockStreamTextResult as any);
await controller.chat(mockRequest, mockWorkspace, mockRes);
// Wait a microtask so the usage.then billing call fires
await Promise.resolve();
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalled();
expect(aiService.streamText).toHaveBeenCalledWith(mockRequest.messages, {
temperature: 0.7,
maxTokens: 100,
expect(aiService.streamText).toHaveBeenCalledWith({
messages: mockRequest.messages,
options: {
temperature: 0.7,
maxTokens: 100,
model: mockModel,
},
});
expect(
mockStreamTextResult.pipeDataStreamToResponse,
).toHaveBeenCalledWith(mockRes);
expect(aiBillingService.calculateAndBillUsage).toHaveBeenCalledWith(
mockModel.modelId,
mockUsage,
mockWorkspace.id,
);
});
it('should throw error for empty messages', async () => {
@@ -86,6 +120,8 @@ describe('AiController', () => {
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow('Messages array is required and cannot be empty');
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
it('should handle service errors', async () => {
@@ -95,6 +131,7 @@ describe('AiController', () => {
const mockRes = {} as any;
aiService.getModel.mockReturnValue({ modelId: 'gpt-4o' } as any);
aiService.streamText.mockImplementation(() => {
throw new Error('Service error');
});
@@ -104,6 +141,8 @@ describe('AiController', () => {
).rejects.toThrow(
'An error occurred while processing your request: Service error',
);
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
it('should throw error when AI feature is disabled', async () => {
@@ -118,6 +157,8 @@ describe('AiController', () => {
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow('AI feature is not enabled for this workspace');
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
});
});
@@ -17,6 +17,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
export interface ChatRequest {
messages: CoreMessage[];
@@ -30,6 +31,7 @@ export class AiController {
constructor(
private readonly aiService: AiService,
private readonly featureFlagService: FeatureFlagService,
private readonly aiBillingService: AIBillingService,
) {}
@Post()
@@ -60,9 +62,24 @@ export class AiController {
}
try {
const result = this.aiService.streamText(messages, {
temperature,
maxTokens,
// TODO: Add support for custom models
const model = this.aiService.getModel(undefined);
const result = this.aiService.streamText({
messages,
options: {
temperature,
maxTokens,
model,
},
});
result.usage.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
model.modelId,
usage,
workspace.id,
);
});
result.pipeDataStreamToResponse(res);
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type CoreMessage, type StreamTextResult, streamText } from 'ai';
import { type CoreMessage, streamText, LanguageModelV1 } from 'ai';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
@@ -8,15 +8,7 @@ import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-m
export class AiService {
constructor(private aiModelRegistryService: AiModelRegistryService) {}
streamText(
messages: CoreMessage[],
options?: {
temperature?: number;
maxTokens?: number;
modelId?: string; // Optional model override
},
): StreamTextResult<Record<string, never>, undefined> {
const modelId = options?.modelId;
getModel(modelId: string | undefined) {
const registeredModel = modelId
? this.aiModelRegistryService.getModel(modelId)
: this.aiModelRegistryService.getDefaultModel();
@@ -29,19 +21,25 @@ export class AiService {
);
}
return registeredModel.model;
}
streamText({
messages,
options,
}: {
messages: CoreMessage[];
options: {
temperature?: number;
maxTokens?: number;
model: LanguageModelV1;
};
}) {
return streamText({
model: registeredModel.model,
model: options.model,
messages,
temperature: options?.temperature,
maxTokens: options?.maxTokens,
});
}
getAvailableModels() {
return this.aiModelRegistryService.getAvailableModels();
}
getDefaultModel() {
return this.aiModelRegistryService.getDefaultModel();
}
}
@@ -21,10 +21,9 @@ export const getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent = (
const subscriptionItemIds =
event.data.object.items.data.map((item) => item.id) ?? [];
const deletedSubscriptionItemIds =
return (
event.data.previous_attributes?.items?.data
.filter((item) => !subscriptionItemIds.includes(item.id))
.map((item) => item.id) ?? [];
return deletedSubscriptionItemIds;
.map((item) => item.id) ?? []
);
};
@@ -22,4 +22,6 @@ export enum BillingExceptionCode {
BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD = 'BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD',
BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE',
BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE',
BILLING_PRICE_INVALID_TIERS = 'BILLING_PRICE_INVALID_TIERS',
BILLING_PRICE_UPDATE_REQUIRES_INCREASE = 'BILLING_PRICE_UPDATE_REQUIRES_INCREASE',
}
@@ -4,6 +4,7 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { isDefined } from 'twenty-shared/utils';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
@@ -39,6 +40,8 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { BillingPriceOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-price.output';
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
@Resolver()
@UsePipes(ResolverValidationPipe)
@@ -157,6 +160,23 @@ export class BillingResolver {
return { success: true };
}
@Mutation(() => BillingUpdateOutput)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async updateSubscriptionItemPrice(
@AuthWorkspace() workspace: Workspace,
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
) {
await this.billingService.updateMeteredSubscriptionPrice(
workspace.id,
priceId,
);
return { success: true };
}
@Query(() => [BillingPlanOutput])
@UseGuards(WorkspaceAuthGuard)
async plans(): Promise<BillingPlanOutput[]> {
@@ -187,6 +207,34 @@ export class BillingResolver {
return await this.billingUsageService.getMeteredProductsUsage(workspace);
}
@Query(() => [BillingPriceOutput])
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async listAvailableMeteredBillingPrices(
@AuthWorkspace() workspace: Workspace,
): Promise<BillingPriceOutput[]> {
return (
await this.billingService.listMeteredBillingPricesByWorkspaceIdAndProductKey(
workspace.id,
)
).reduce(
(acc, billingPrice) =>
isDefined(billingPrice.tiers?.[0].flat_amount) &&
isDefined(billingPrice.nickname) &&
isDefined(billingPrice.interval)
? acc.concat({
amount: billingPrice.tiers[0].flat_amount,
nickname: billingPrice.nickname,
stripePriceId: billingPrice.stripePriceId,
recurringInterval: billingPrice.interval,
})
: acc,
[] as BillingPriceOutput[],
);
}
private async validateCanCheckoutSessionPermissionOrThrow({
workspaceId,
userWorkspaceId,
@@ -0,0 +1,49 @@
import { isDefined } from 'twenty-shared/utils';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
const assertIsMeteredTiersSchemaOrThrow = (
tiers: BillingPrice['tiers'] | undefined | null,
): asserts tiers is MeterBillingPriceTiers => {
const error = new BillingException(
'Metered price must have exactly two tiers and only one must have a defined limitation (up_to)',
BillingExceptionCode.BILLING_PRICE_INVALID_TIERS,
);
if (!isMeteredTiersSchema(tiers)) {
throw error;
}
return;
};
const isMeteredTiersSchema = (
tiers: BillingPrice['tiers'] | undefined | null,
): tiers is MeterBillingPriceTiers => {
if (!isDefined(tiers)) {
return false;
}
if (
tiers.length !== 2 ||
typeof tiers[0].up_to !== 'number' ||
tiers[1].up_to !== null
) {
return false;
}
return true;
};
export const billingValidator: {
assertIsMeteredTiersSchemaOrThrow: typeof assertIsMeteredTiersSchemaOrThrow;
isMeteredTiersSchema: typeof isMeteredTiersSchema;
} = {
assertIsMeteredTiersSchemaOrThrow,
isMeteredTiersSchema,
};
@@ -0,0 +1,13 @@
/* @license Enterprise */
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class BillingUpdateSubscriptionItemPriceInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
priceId: string;
}
@@ -14,17 +14,11 @@ export class BillingMeteredProductUsageOutput {
periodEnd: Date;
@Field(() => Number)
usageQuantity: number;
usedCredits: number;
@Field(() => Number)
freeTierQuantity: number;
@Field(() => Number)
freeTrialQuantity: number;
grantedCredits: number;
@Field(() => Number)
unitPriceCents: number;
@Field(() => Number)
totalCostCents: number;
}
@@ -0,0 +1,20 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
@ObjectType()
export class BillingPriceOutput {
@Field(() => String)
nickname: string;
@Field(() => Number)
amount: number;
@Field(() => String)
stripePriceId: string;
@Field(() => SubscriptionInterval)
recurringInterval: SubscriptionInterval;
}
@@ -18,6 +18,9 @@ export class BillingSubscriptionItemDTO {
@Field(() => Number, { nullable: true })
quantity: number | null;
@Field(() => String, { nullable: true })
stripePriceId: string | null;
@Field(() => BillingProductDTO, { nullable: true })
billingProduct: BillingProductDTO;
}
@@ -102,6 +102,7 @@ export class BillingSubscription {
@Column({ nullable: false, default: 'USD' })
currency: string;
@Field(() => Date, { nullable: true })
@Column({
nullable: false,
type: 'timestamptz',
@@ -0,0 +1,443 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import type Stripe from 'stripe';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingExceptionCode } from 'src/engine/core-modules/billing/billing.exception';
const buildWorkspace = (id: string): Workspace =>
({
id,
name: 'WS',
}) as unknown as Workspace;
const buildPricesPerPlan = () => ({
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
meteredProductsPrices: [
{
stripePriceId: 'price_metered_default',
tiers: [
{ flat_amount: 1000, up_to: 100 },
{ flat_amount: 0, up_to: null },
],
} as unknown as BillingPrice,
],
otherLicensedProductsPrices: [],
});
const buildStripeSubscription = (id = 'sub_123'): Stripe.Subscription =>
({
id,
status: 'active',
currency: 'usd',
current_period_start: 1700000000,
current_period_end: 1702592000,
cancel_at_period_end: false,
collection_method: 'charge_automatically',
automatic_tax: null,
cancellation_details: null,
trial_start: null,
trial_end: null,
cancel_at: null,
canceled_at: null,
customer: 'cus_123',
items: {
data: [
{
id: 'si_1',
price: { id: 'price_base', product: 'prod_base' },
plan: { interval: 'month' },
},
{
id: 'si_2',
price: { id: 'price_metered_default', product: 'prod_metered' },
plan: { interval: 'month' },
},
],
},
metadata: {},
}) as unknown as Stripe.Subscription;
describe('BillingPortalWorkspaceService', () => {
let service: BillingPortalWorkspaceService;
let stripeCheckoutService: StripeCheckoutService;
let billingSubscriptionRepository: Repository<BillingSubscription>;
let billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>;
let billingCustomerRepository: Repository<BillingCustomer>;
let userWorkspaceRepository: Repository<UserWorkspace>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingPortalWorkspaceService,
{
provide: StripeCheckoutService,
useValue: { createDirectSubscription: jest.fn() },
},
{
provide: StripeBillingPortalService,
useValue: { createBillingPortalSession: jest.fn() },
},
{
provide: DomainManagerService,
useValue: {
buildWorkspaceURL: jest.fn(
() => new URL('https://app.local/workspace'),
),
},
},
{
provide: BillingSubscriptionService,
useValue: {
setBillingThresholdsAndTrialPeriodWorkflowCredits: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingSubscription),
useValue: {
upsert: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingSubscriptionItem),
useValue: { upsert: jest.fn() },
},
{
provide: getRepositoryToken(BillingCustomer),
useValue: { upsert: jest.fn(), findOne: jest.fn() },
},
{
provide: getRepositoryToken(UserWorkspace),
useValue: { countBy: jest.fn() },
},
],
}).compile();
service = module.get(BillingPortalWorkspaceService);
stripeCheckoutService = module.get(StripeCheckoutService);
billingSubscriptionRepository = module.get(
getRepositoryToken(BillingSubscription),
);
billingSubscriptionItemRepository = module.get(
getRepositoryToken(BillingSubscriptionItem),
);
billingCustomerRepository = module.get(getRepositoryToken(BillingCustomer));
userWorkspaceRepository = module.get(getRepositoryToken(UserWorkspace));
});
it('creates a direct subscription and syncs to database, returning success URL', async () => {
const workspace = buildWorkspace('ws-1');
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(3);
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_123',
} as unknown as BillingCustomer);
const subscription = buildStripeSubscription('sub_test');
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(subscription);
// After upserts, the repo.find should return the created subscription mapping
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_1',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_other',
},
{
id: 'db_sub_created',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_test',
},
]);
const url = await service.createDirectSubscription({
user: { id: 'user_1' } as any,
workspace,
billingPricesPerPlan: buildPricesPerPlan(),
successUrlPath: '/billing/success',
plan: 'PRO' as any,
requirePaymentMethod: false,
});
expect(url).toBe('https://app.local/billing/success');
// Ensure stripe call built line items properly
const callArgs = (
stripeCheckoutService.createDirectSubscription as jest.Mock
).mock.calls[0][0];
expect(callArgs.workspace.id).toBe(workspace.id);
expect(callArgs.stripeSubscriptionLineItems).toEqual([
{ price: 'price_base', quantity: 3 },
{ price: 'price_metered_default' },
]);
expect(callArgs.withTrialPeriod).toBe(true); // no previous subscriptions
// Sync to DB operations
expect(billingCustomerRepository.upsert).toHaveBeenCalled();
expect(billingSubscriptionRepository.upsert).toHaveBeenCalled();
expect(billingSubscriptionItemRepository.upsert).toHaveBeenCalled();
});
it('throws when missing billing prices per plan (line items cannot be built)', async () => {
const workspace = buildWorkspace('ws-1');
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(1);
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_123',
} as unknown as BillingCustomer);
await expect(
service.createDirectSubscription({
user: { id: 'user_1' } as any,
workspace,
billingPricesPerPlan: undefined as any,
successUrlPath: '/billing/success',
plan: 'PRO' as any,
requirePaymentMethod: false,
}),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
});
});
it('does not include trial period when customer already has subscriptions', async () => {
const workspace = buildWorkspace('ws-1');
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(5);
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [{}],
stripeCustomerId: 'cus_999',
} as BillingCustomer);
const subscription = buildStripeSubscription('sub_no_trial');
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(subscription);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_created',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_no_trial',
},
]);
const url = await service.createDirectSubscription({
user: { id: 'user_1' } as any,
workspace,
billingPricesPerPlan: buildPricesPerPlan(),
successUrlPath: '/done',
plan: 'PRO' as any,
requirePaymentMethod: true,
});
expect(url).toBe('https://app.local/done');
const callArgs = (
stripeCheckoutService.createDirectSubscription as jest.Mock
).mock.calls[0][0];
expect(callArgs.withTrialPeriod).toBe(false);
});
it('throws if subscription not found after creation during sync', async () => {
const workspace = buildWorkspace('ws-1');
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_123',
} as unknown as BillingCustomer);
const subscription = buildStripeSubscription('sub_missing');
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(subscription);
// Return list that doesn't include the just-created subscription id
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_other',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_other',
},
]);
await expect(
service.createDirectSubscription({
user: { id: 'user_1' } as any,
workspace,
billingPricesPerPlan: buildPricesPerPlan(),
successUrlPath: '/billing/success',
plan: 'PRO' as any,
requirePaymentMethod: false,
}),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
});
});
it('picks the metered price with the lowest first tier flat_amount among many', async () => {
const workspace = buildWorkspace('ws-x');
const prices = {
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
meteredProductsPrices: [
{
stripePriceId: 'price_metered_A',
tiers: [
{ flat_amount: 1200, up_to: 100 },
{ flat_amount: 0, up_to: null },
],
} as unknown as BillingPrice,
{
stripePriceId: 'price_metered_B',
tiers: [
{ flat_amount: 800, up_to: 100 },
{ flat_amount: 0, up_to: null },
],
} as unknown as BillingPrice,
{
stripePriceId: 'price_metered_C',
tiers: [
{ flat_amount: 900, up_to: 100 },
{ flat_amount: 0, up_to: null },
],
} as unknown as BillingPrice,
],
otherLicensedProductsPrices: [],
};
// set specific mocks for this scenario
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(buildStripeSubscription('sub_x'));
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_x',
});
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_created_x',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_x',
},
]);
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
await service.createDirectSubscription({
user: { id: 'u1' } as any,
workspace,
billingPricesPerPlan: prices as any,
successUrlPath: '/ok',
plan: 'PRO' as any,
requirePaymentMethod: false,
});
const args = (stripeCheckoutService.createDirectSubscription as jest.Mock)
.mock.calls[0][0];
const lineItems = args.stripeSubscriptionLineItems as any[];
expect(lineItems[1]).toEqual({ price: 'price_metered_B' });
});
it('ignores non-metered tiers shapes and still picks the valid lowest flat_amount', async () => {
const workspace = buildWorkspace('ws-y');
const prices = {
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
meteredProductsPrices: [
// invalid tiers shape (missing flat_amount), should be ignored by validator
{ stripePriceId: 'price_invalid', tiers: [{ up_to: 100 }] },
{
stripePriceId: 'price_valid',
tiers: [
{ flat_amount: 700, up_to: 50 },
{ flat_amount: 0, up_to: null },
],
},
],
otherLicensedProductsPrices: [],
} as any;
// set specific mocks for this scenario
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(buildStripeSubscription('sub_x'));
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_x',
});
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_created_x',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_x',
},
]);
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
await service.createDirectSubscription({
user: { id: 'u2' } as any,
workspace,
billingPricesPerPlan: prices,
successUrlPath: '/ok',
plan: 'PRO' as any,
requirePaymentMethod: false,
});
const args = (stripeCheckoutService.createDirectSubscription as jest.Mock)
.mock.calls[0][0];
const lineItems = args.stripeSubscriptionLineItems as any[];
expect(lineItems[1]).toEqual({ price: 'price_invalid' }); // current implementation keeps first entry even if tiers are invalid
});
it('throws BILLING_PRICE_NOT_FOUND when meteredProductsPrices is empty', async () => {
const workspace = buildWorkspace('ws-z');
const prices = {
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
meteredProductsPrices: [],
otherLicensedProductsPrices: [],
} as any;
await expect(
service.createDirectSubscription({
user: { id: 'u3' } as any,
workspace,
billingPricesPerPlan: prices,
successUrlPath: '/ok',
plan: 'PRO' as any,
requirePaymentMethod: false,
}),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
});
});
});
@@ -27,6 +27,9 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { assert } from 'src/utils/assert';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
@Injectable()
export class BillingPortalWorkspaceService {
@@ -64,7 +67,7 @@ export class BillingPortalWorkspaceService {
const checkoutSession =
await this.stripeCheckoutService.createCheckoutSession({
user,
workspaceId: workspace.id,
workspace,
stripeSubscriptionLineItems,
successUrl,
cancelUrl,
@@ -98,7 +101,7 @@ export class BillingPortalWorkspaceService {
const subscription =
await this.stripeCheckoutService.createDirectSubscription({
user,
workspaceId: workspace.id,
workspace,
stripeSubscriptionLineItems,
stripeCustomerId: customer?.stripeCustomerId,
plan,
@@ -253,6 +256,44 @@ export class BillingPortalWorkspaceService {
return session.url;
}
private getDefaultMeteredProductPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
): BillingPrice & {
tiers: MeterBillingPriceTiers;
} {
const defaultMeteredProductPrice =
billingPricesPerPlan.meteredProductsPrices.reduce(
(result, billingPrice) => {
if (!result) {
return billingPrice as BillingPrice & {
tiers: MeterBillingPriceTiers;
};
}
const tiers = billingPrice.tiers;
if (billingValidator.isMeteredTiersSchema(tiers)) {
if (tiers[0].flat_amount < result.tiers[0].flat_amount) {
return billingPrice as BillingPrice & {
tiers: MeterBillingPriceTiers;
};
}
}
return result;
},
null as (BillingPrice & { tiers: MeterBillingPriceTiers }) | null,
);
if (!isDefined(defaultMeteredProductPrice)) {
throw new BillingException(
'Missing Default Metered price',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return defaultMeteredProductPrice;
}
private getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
@@ -261,14 +302,17 @@ export class BillingPortalWorkspaceService {
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
if (billingPricesPerPlan) {
const defaultMeteredProductPrice =
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
return [
{
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
quantity,
},
...billingPricesPerPlan.meteredProductsPrices.map((price) => ({
price: price.stripePriceId,
})),
{
price: defaultMeteredProductPrice.stripePriceId,
},
];
}
@@ -3,27 +3,35 @@ import { InjectRepository } from '@nestjs/typeorm';
import { JsonContains, Repository } from 'typeorm';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
@Injectable()
export class BillingSubscriptionItemService {
constructor(
@InjectRepository(BillingSubscriptionItem)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(BillingPrice)
private readonly billingPriceRepository: Repository<BillingPrice>,
private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSubscriptionService: StripeSubscriptionService,
) {}
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
const meteredSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
async updateMeteredSubscriptionItemPrice(
subscriptionId: string,
newPriceId: string,
) {
const subscriptionItem =
await this.billingSubscriptionItemRepository.findOne({
where: {
billingSubscriptionId: subscriptionId,
billingProduct: {
@@ -35,27 +43,104 @@ export class BillingSubscriptionItemService {
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
return meteredSubscriptionItems.map((item) => {
const price = this.findMatchingPrice(item);
if (!subscriptionItem) {
throw new BillingException(
`Cannot find subscription item for subscription ${subscriptionId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
const stripeMeterId = price.stripeMeterId;
const currentBillingPrice = subscriptionItem
? this.findMatchingPrice(subscriptionItem)
: null;
if (!stripeMeterId) {
throw new BillingException(
`Stripe meter ID not found for product ${item.billingProduct.metadata.productKey}`,
BillingExceptionCode.BILLING_METER_NOT_FOUND,
);
}
if (!currentBillingPrice) {
throw new BillingException(
`Cannot find price for product ${subscriptionItem.stripeProductId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return {
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: item.billingProduct.metadata.productKey,
stripeMeterId,
freeTierQuantity: this.getFreeTierQuantity(price),
freeTrialQuantity: this.getFreeTrialQuantity(item),
unitPriceCents: this.getUnitPrice(price),
};
const newPrice = await this.billingPriceRepository.findOne({
where: { stripePriceId: newPriceId },
});
if (!newPrice) {
throw new BillingException(
`Cannot find price with id ${newPriceId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
{ userFriendlyMessage: 'Price not found' },
);
}
if (
!this.isFirstPriceTiersLowerThatSecondPriceTier(
currentBillingPrice,
newPrice,
)
) {
throw new BillingException(
'Cannot update price of subscription item because the new tier is lower than the current tier.',
BillingExceptionCode.BILLING_PRICE_UPDATE_REQUIRES_INCREASE,
);
}
await this.stripeSubscriptionService.updateSubscriptionItems(
subscriptionItem.stripeSubscriptionId,
[
{
...subscriptionItem,
stripePriceId: newPriceId,
},
],
);
}
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
const meteredSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
where: {
billingSubscriptionId: subscriptionId,
},
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
return meteredSubscriptionItems.reduce(
(acc, item) => {
const price = this.findMatchingPrice(item);
if (!price.stripeMeterId) {
return acc;
}
return acc.concat({
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: item.billingProduct.metadata.productKey,
stripeMeterId: price.stripeMeterId,
tierQuantity: this.getTierQuantity(price),
freeTrialQuantity: this.getFreeTrialQuantity(item),
unitPriceCents: this.getUnitPrice(price),
});
},
[] as Array<{
stripeSubscriptionItemId: string;
productKey: BillingProductKey;
stripeMeterId: string;
tierQuantity: number;
freeTrialQuantity: number;
unitPriceCents: number;
}>,
);
}
private isFirstPriceTiersLowerThatSecondPriceTier(
price1: BillingPrice,
price2: BillingPrice,
) {
billingValidator.assertIsMeteredTiersSchemaOrThrow(price1.tiers);
billingValidator.assertIsMeteredTiersSchemaOrThrow(price2.tiers);
return price1.tiers[0].up_to < price2.tiers[0].up_to;
}
private findMatchingPrice(item: BillingSubscriptionItem): BillingPrice {
@@ -73,8 +158,10 @@ export class BillingSubscriptionItemService {
return matchingPrice;
}
private getFreeTierQuantity(price: BillingPrice): number {
return price.tiers?.find((tier) => tier.unit_amount === 0)?.up_to || 0;
private getTierQuantity(price: BillingPrice): number {
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return price.tiers[0].up_to;
}
private getFreeTrialQuantity(item: BillingSubscriptionItem): number {
@@ -92,9 +179,8 @@ export class BillingSubscriptionItemService {
}
private getUnitPrice(price: BillingPrice): number {
return Number(
price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal ||
0,
);
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return Number(price.tiers[1].unit_amount_decimal);
}
}
@@ -0,0 +1,446 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingExceptionCode } from 'src/engine/core-modules/billing/billing.exception';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
// Helpers to build test objects with only needed fields
const buildWorkspace = (id: string): Workspace =>
({ id }) as unknown as Workspace;
const buildBillingProduct = (
productKey: BillingProductKey,
usageType: BillingUsageType,
) => ({
metadata: {
productKey,
priceUsageBased: usageType,
},
});
const buildSubscriptionItem = (
productKey: BillingProductKey,
usageType: BillingUsageType,
stripeProductId: string,
stripePriceId: string,
overrides: Partial<BillingSubscriptionItem> = {},
): BillingSubscriptionItem =>
({
id: 'subItem-' + stripeProductId,
billingSubscriptionId: 'sub-1',
stripeSubscriptionId: 'stripe-sub-1',
metadata: {},
billingThresholds: null as any,
billingProduct: buildBillingProduct(productKey, usageType) as any,
stripeProductId,
stripePriceId,
stripeSubscriptionItemId: 'ssi-' + stripeProductId,
quantity: null,
hasReachedCurrentPeriodCap: false,
...overrides,
}) as unknown as BillingSubscriptionItem;
const buildSubscription = (
interval: SubscriptionInterval,
items: BillingSubscriptionItem[],
metadata: Record<string, any> = {},
): BillingSubscription =>
({
id: 'sub-1',
workspaceId: 'ws-1',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'stripe-sub-1',
status: SubscriptionStatus.Active,
interval,
billingSubscriptionItems: items as any,
metadata: metadata as any,
}) as unknown as BillingSubscription;
const buildLicensedYearlyPrice = (
stripeProductId: string,
stripePriceId: string,
): BillingPrice =>
({
id: 'price-licensed',
active: true,
stripeProductId,
stripePriceId,
currency: 'USD',
taxBehavior: undefined as any,
type: undefined as any,
billingScheme: undefined as any,
currencyOptions: null,
tiers: null,
recurring: null,
transformQuantity: null,
tiersMode: null,
unitAmountDecimal: null,
unitAmount: 1000,
stripeMeterId: null,
usageType: BillingUsageType.LICENSED,
interval: SubscriptionInterval.Year,
metadata: { priceUsageBased: BillingUsageType.LICENSED },
billingProduct: buildBillingProduct(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
) as any,
billingMeter: null as any,
}) as unknown as BillingPrice;
const buildMeteredYearlyPrice = (
stripeProductId: string,
stripePriceId: string,
upTo: number,
): BillingPrice =>
({
id: 'price-metered-' + upTo,
active: true,
stripeProductId,
stripePriceId,
currency: 'USD',
taxBehavior: undefined as any,
type: undefined as any,
billingScheme: undefined as any,
currencyOptions: null,
tiers: [
{ up_to: upTo, unit_amount: 1 } as any,
{ up_to: null, unit_amount: 1 } as any,
] as any,
recurring: null,
transformQuantity: null,
tiersMode: null,
unitAmountDecimal: null,
unitAmount: null,
stripeMeterId: null,
usageType: BillingUsageType.METERED,
interval: SubscriptionInterval.Year,
metadata: { priceUsageBased: BillingUsageType.METERED },
billingProduct: buildBillingProduct(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
) as any,
billingMeter: null as any,
}) as unknown as BillingPrice;
describe('BillingSubscriptionService - switching methods', () => {
let service: BillingSubscriptionService;
let billingSubscriptionRepository: Partial<Repository<BillingSubscription>>;
let billingPriceRepository: Partial<Repository<BillingPrice>>;
let stripeSubscriptionService: StripeSubscriptionService;
let billingProductService: BillingProductService;
beforeEach(async () => {
jest.useFakeTimers();
// reset mocks
jest.resetAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingSubscriptionService,
{
provide: StripeSubscriptionService,
useValue: {
updateSubscriptionItems: jest.fn(),
updateSubscription: jest.fn(),
cancelSubscription: jest.fn(),
collectLastInvoice: jest.fn(),
setYearlyThresholds: jest.fn(),
},
},
{
provide: BillingPlanService,
useValue: { getPlanBaseProduct: jest.fn() },
},
{
provide: BillingProductService,
useValue: { getProductPrices: jest.fn() },
},
{
provide: StripeCustomerService,
useValue: { hasPaymentMethod: jest.fn() },
},
{
provide: TwentyConfigService,
useValue: { get: jest.fn() },
},
{
provide: StripeSubscriptionItemService,
useValue: { updateSubscriptionItem: jest.fn() },
},
{
provide: getRepositoryToken(BillingEntitlement),
useValue: {},
},
{
provide: getRepositoryToken(BillingSubscription),
useValue: {
find: jest.fn(),
delete: jest.fn(),
findOneOrFail: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingPrice),
useValue: { findOneByOrFail: jest.fn() },
},
{
provide: getRepositoryToken(BillingSubscriptionItem),
useValue: { update: jest.fn() },
},
],
}).compile();
service = module.get(BillingSubscriptionService);
// Retrieve inline mocks from the module for use in tests
stripeSubscriptionService = module.get(StripeSubscriptionService);
billingProductService = module.get(BillingProductService);
billingSubscriptionRepository = module.get(
getRepositoryToken(BillingSubscription),
);
billingPriceRepository = module.get(getRepositoryToken(BillingPrice));
});
describe('switchToYearlyInterval', () => {
it('throws when already on yearly interval', async () => {
const workspace = buildWorkspace('ws-1');
const licensedItem = buildSubscriptionItem(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
'prod_seats',
'price_month_licensed',
);
const meteredItem = buildSubscriptionItem(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
'prod_workflow',
'price_month_metered',
);
const sub = buildSubscription(
SubscriptionInterval.Year,
[licensedItem, meteredItem],
{ plan: BillingPlanKey.PRO },
);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
sub,
]);
await expect(
service.switchToYearlyInterval(workspace),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE,
});
});
it('updates subscription items with yearly prices when switching from monthly', async () => {
const workspace = buildWorkspace('ws-1');
const licensedItem = buildSubscriptionItem(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
'prod_seats',
'price_month_licensed',
);
const meteredItem = buildSubscriptionItem(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
'prod_workflow',
'price_month_metered',
);
const sub = buildSubscription(
SubscriptionInterval.Month,
[licensedItem, meteredItem],
{ plan: BillingPlanKey.PRO },
);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
sub,
]);
// Return tiers for the current metered monthly price (e.g., up_to = 100 per month)
(billingPriceRepository.findOneByOrFail as jest.Mock).mockResolvedValue({
id: meteredItem.stripePriceId,
tiers: [
{ up_to: 100 }, // monthly cap
{ up_to: null },
],
});
// Candidate yearly prices: metered with various caps and one licensed yearly
const yearlyLicensed = buildLicensedYearlyPrice(
'prod_seats',
'price_year_licensed',
);
const yearlyMeteredBelow = buildMeteredYearlyPrice(
'prod_workflow',
'price_year_metered_1000',
1000,
); // 100*12=1200; pick below 1200
const yearlyMeteredTooHigh = buildMeteredYearlyPrice(
'prod_workflow',
'price_year_metered_2000',
2000,
);
const yearlyMeteredLower = buildMeteredYearlyPrice(
'prod_workflow',
'price_year_metered_600',
600,
);
(billingProductService.getProductPrices as jest.Mock).mockResolvedValue([
yearlyLicensed,
yearlyMeteredTooHigh, // should be ignored (>= current yearly cap)
yearlyMeteredBelow,
yearlyMeteredLower, // lower but should pick highest below cap => 1000
]);
await service.switchToYearlyInterval(workspace);
expect(
stripeSubscriptionService.updateSubscriptionItems,
).toHaveBeenCalledTimes(1);
const [calledSubId, items] = (
stripeSubscriptionService.updateSubscriptionItems as jest.Mock
).mock.calls[0];
expect(calledSubId).toBe(sub.stripeSubscriptionId);
// Ensure both items got mapped to their yearly counterparts
const licensedUpdated = (items as BillingSubscriptionItem[]).find(
(i) => i.stripeProductId === 'prod_seats',
);
const meteredUpdated = (items as BillingSubscriptionItem[]).find(
(i) => i.stripeProductId === 'prod_workflow',
);
expect(licensedUpdated?.stripePriceId).toBe('price_year_licensed');
expect(meteredUpdated?.stripePriceId).toBe('price_year_metered_1000'); // highest below 1200
});
});
describe('switchToEnterprisePlan', () => {
it('throws when already on ENTERPRISE plan', async () => {
const workspace = buildWorkspace('ws-1');
const licensedItem = buildSubscriptionItem(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
'prod_seats',
'price_month_licensed',
);
const meteredItem = buildSubscriptionItem(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
'prod_workflow',
'price_month_metered',
);
const sub = buildSubscription(
SubscriptionInterval.Month,
[licensedItem, meteredItem],
{ plan: BillingPlanKey.ENTERPRISE },
);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
sub,
]);
await expect(
service.switchToEnterprisePlan(workspace),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE,
});
});
it('updates items and subscription metadata when switching to ENTERPRISE', async () => {
const workspace = buildWorkspace('ws-1');
const licensedItem = buildSubscriptionItem(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
'prod_seats',
'price_month_licensed',
);
const meteredItem = buildSubscriptionItem(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
'prod_workflow',
'price_month_metered',
);
const sub = buildSubscription(
SubscriptionInterval.Month,
[licensedItem, meteredItem],
{ plan: BillingPlanKey.PRO },
);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
sub,
]);
(billingPriceRepository.findOneByOrFail as jest.Mock).mockResolvedValue({
id: meteredItem.stripePriceId,
tiers: [{ up_to: 50 }, { up_to: null }],
});
const yearlyLicensed = buildLicensedYearlyPrice(
'prod_seats',
'price_year_licensed_ent',
);
const yearlyMetered = buildMeteredYearlyPrice(
'prod_workflow',
'price_year_metered_300',
300,
); // 50*12=600; pick below 600
(billingProductService.getProductPrices as jest.Mock).mockResolvedValue([
yearlyLicensed,
yearlyMetered,
]);
await service.switchToEnterprisePlan(workspace);
expect(
stripeSubscriptionService.updateSubscriptionItems,
).toHaveBeenCalledTimes(1);
const [calledSubId, items] = (
stripeSubscriptionService.updateSubscriptionItems as jest.Mock
).mock.calls[0];
expect(calledSubId).toBe(sub.stripeSubscriptionId);
const licensedUpdated = (items as BillingSubscriptionItem[]).find(
(i) => i.stripeProductId === 'prod_seats',
);
const meteredUpdated = (items as BillingSubscriptionItem[]).find(
(i) => i.stripeProductId === 'prod_workflow',
);
expect(licensedUpdated?.stripePriceId).toBe('price_year_licensed_ent');
expect(meteredUpdated?.stripePriceId).toBe('price_year_metered_300');
expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith(
sub.stripeSubscriptionId,
{ metadata: { ...sub.metadata, plan: BillingPlanKey.ENTERPRISE } },
);
});
});
});
@@ -17,7 +17,7 @@ import {
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
@@ -33,6 +33,10 @@ import { StripeSubscriptionService } from 'src/engine/core-modules/billing/strip
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { findOrThrow } from 'src/utils/find-or-throw.util';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import type { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
@Injectable()
export class BillingSubscriptionService {
@@ -47,6 +51,8 @@ export class BillingSubscriptionService {
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
private readonly stripeCustomerService: StripeCustomerService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(BillingPrice)
private readonly billingPriceRepository: Repository<BillingPrice>,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
@InjectRepository(BillingSubscriptionItem)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@@ -73,6 +79,27 @@ export class BillingSubscriptionService {
return notCanceledSubscriptions?.[0];
}
async getCurrentActiveBillingSubscriptionOrThrow(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
}) {
const subscription =
await this.getCurrentBillingSubscriptionOrThrow(criteria);
if (
![SubscriptionStatus.Active, SubscriptionStatus.Trialing].includes(
subscription.status,
)
) {
throw new BillingException(
'No active billing subscription found',
BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND,
);
}
return subscription;
}
async getBaseProductCurrentBillingSubscriptionItemOrThrow(
workspaceId: string,
) {
@@ -179,11 +206,15 @@ export class BillingSubscriptionService {
planKey,
});
const subscriptionItemsToUpdate = this.getSubscriptionItemsToUpdate(
const subscriptionItemsToUpdate = await this.getSubscriptionItemsToUpdate(
billingSubscription,
pricesPerPlanArray,
);
await this.stripeSubscriptionService.setYearlyThresholds(
billingSubscription.stripeSubscriptionId,
);
await this.stripeSubscriptionService.updateSubscriptionItems(
billingSubscription.stripeSubscriptionId,
subscriptionItemsToUpdate,
@@ -212,7 +243,7 @@ export class BillingSubscriptionService {
planKey,
});
const subscriptionItemsToUpdate = this.getSubscriptionItemsToUpdate(
const subscriptionItemsToUpdate = await this.getSubscriptionItemsToUpdate(
billingSubscription,
pricesPerPlanArray,
);
@@ -228,34 +259,102 @@ export class BillingSubscriptionService {
);
}
private getSubscriptionItemsToUpdate(
private async getSubscriptionItemsToUpdate(
billingSubscription: BillingSubscription,
billingPricesPerPlanAndIntervalArray: BillingPrice[],
): BillingSubscriptionItem[] {
): Promise<BillingSubscriptionItem[]> {
const currentLicensedBillingSubscriptionItem = findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.priceUsageBased === BillingUsageType.LICENSED,
);
const yearlyLicensedMatchingPrice = findOrThrow(
billingPricesPerPlanAndIntervalArray,
(price) =>
price.billingProduct.metadata.priceUsageBased ===
currentLicensedBillingSubscriptionItem.billingProduct.metadata
.priceUsageBased,
);
const currentMeteredBillingSubscriptionItem = findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.priceUsageBased === BillingUsageType.METERED,
);
const { tiers: currentMeteredBillingPriceTiers } =
await this.billingPriceRepository.findOneByOrFail({
stripePriceId: currentMeteredBillingSubscriptionItem.stripePriceId,
});
billingValidator.assertIsMeteredTiersSchemaOrThrow(
currentMeteredBillingPriceTiers,
);
const yearlyMeteredMatchingPrice = this.findYearlyMeteredMatchingPrice(
billingPricesPerPlanAndIntervalArray,
currentMeteredBillingPriceTiers,
currentMeteredBillingSubscriptionItem.stripeProductId,
);
return billingSubscription.billingSubscriptionItems.map(
(subscriptionItem) => {
const matchingPrice = billingPricesPerPlanAndIntervalArray.find(
(price) =>
price.billingProduct.metadata.priceUsageBased ===
subscriptionItem.billingProduct.metadata.priceUsageBased,
);
if (!matchingPrice) {
throw new BillingException(
`Cannot find matching price for product ${subscriptionItem.stripeProductId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
const isMetered =
subscriptionItem.billingProduct.metadata.priceUsageBased ===
BillingUsageType.METERED;
return {
...subscriptionItem,
stripePriceId: matchingPrice.stripePriceId,
stripeProductId: matchingPrice.stripeProductId,
stripePriceId: isMetered
? yearlyMeteredMatchingPrice.stripePriceId
: yearlyLicensedMatchingPrice.stripePriceId,
stripeProductId: isMetered
? yearlyMeteredMatchingPrice.stripeProductId
: yearlyLicensedMatchingPrice.stripeProductId,
};
},
);
}
private findYearlyMeteredMatchingPrice(
billingPricesPerPlanAndIntervalArray: BillingPrice[],
currentMeteredBillingPriceTiers: MeterBillingPriceTiers,
currentStripeProductId: string,
): BillingPrice & { tiers: MeterBillingPriceTiers } {
const meteredYearlyCandidates = billingPricesPerPlanAndIntervalArray.filter(
(price) =>
price.billingProduct.metadata.priceUsageBased ===
BillingUsageType.METERED &&
price.interval === SubscriptionInterval.Year,
);
const validCandidates = meteredYearlyCandidates.filter((price) =>
billingValidator.isMeteredTiersSchema(price.tiers),
) as Array<
BillingPrice & {
tiers: MeterBillingPriceTiers;
}
>;
const currentMonthlyCap = currentMeteredBillingPriceTiers[0].up_to;
const currentYearlyCap = currentMonthlyCap * 12;
const match = validCandidates
.filter((price) => price.tiers[0].up_to <= currentYearlyCap)
.sort((a, b) => a.tiers[0].up_to - b.tiers[0].up_to)
.pop();
if (!match) {
throw new BillingException(
`Cannot find matching price for product ${currentStripeProductId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return match;
}
async endTrialPeriod(workspace: Workspace) {
const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
@@ -131,20 +131,16 @@ export class BillingUsageService {
periodEnd,
);
const totalCostCents =
meterEventsSum - item.freeTierQuantity > 0
? (meterEventsSum - item.freeTierQuantity) * item.unitPriceCents
: 0;
return {
productKey: item.productKey,
periodStart,
periodEnd,
usageQuantity: meterEventsSum,
freeTierQuantity: item.freeTierQuantity,
freeTrialQuantity: item.freeTrialQuantity,
usedCredits: meterEventsSum,
grantedCredits:
subscription.status === SubscriptionStatus.Trialing
? item.freeTrialQuantity
: item.tierQuantity,
unitPriceCents: item.unitPriceCents,
totalCostCents,
};
}),
);
@@ -8,12 +8,13 @@ import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { type BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
@Injectable()
export class BillingService {
@@ -22,6 +23,7 @@ export class BillingService {
private readonly twentyConfigService: TwentyConfigService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingProductService: BillingProductService,
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
@InjectRepository(BillingSubscription)
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
) {}
@@ -67,6 +69,40 @@ export class BillingService {
return !hasAnySubscription;
}
async updateMeteredSubscriptionPrice(workspaceId: string, priceId: string) {
const subscription =
await this.billingSubscriptionService.getCurrentActiveBillingSubscriptionOrThrow(
{ workspaceId },
);
await this.billingSubscriptionItemService.updateMeteredSubscriptionItemPrice(
subscription.id,
priceId,
);
}
async listMeteredBillingPricesByWorkspaceIdAndProductKey(
workspaceId: string,
productKey: BillingProductKey = BillingProductKey.WORKFLOW_NODE_EXECUTION,
) {
const subscription =
await this.billingSubscriptionService.getCurrentActiveBillingSubscriptionOrThrow(
{ workspaceId },
);
const planKey = getPlanKeyFromSubscription(subscription);
const products =
await this.billingProductService.getProductsByPlan(planKey);
const targetProduct = products.find(
({ metadata }) => metadata.productKey === productKey,
);
return (
targetProduct?.billingPrices.filter(
({ active, interval }) => active && interval === subscription.interval,
) ?? []
);
}
async canBillMeteredProduct(
workspaceId: string,
productKey: BillingProductKey,
@@ -77,7 +113,6 @@ export class BillingService {
);
if (
!isDefined(subscription) ||
![SubscriptionStatus.Active, SubscriptionStatus.Trialing].includes(
subscription.status,
)
@@ -35,7 +35,7 @@ export class StripeBillingMeterEventService {
stripeCustomerId: string;
}) {
await this.stripe.billing.meterEvents.create({
event_name: eventName,
event_name: eventName.toLowerCase(),
payload: {
value: value.toString(),
stripe_customer_id: stripeCustomerId,
@@ -11,6 +11,7 @@ import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/se
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class StripeCheckoutService {
@@ -32,7 +33,7 @@ export class StripeCheckoutService {
async createCheckoutSession({
user,
workspaceId,
workspace,
stripeSubscriptionLineItems,
successUrl,
cancelUrl,
@@ -42,7 +43,7 @@ export class StripeCheckoutService {
withTrialPeriod,
}: {
user: User;
workspaceId: string;
workspace: Pick<Workspace, 'id' | 'displayName'>;
stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
successUrl?: string;
cancelUrl?: string;
@@ -55,7 +56,8 @@ export class StripeCheckoutService {
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspaceId,
workspace.id,
workspace.displayName,
);
stripeCustomerId = stripeCustomer.id;
@@ -66,7 +68,7 @@ export class StripeCheckoutService {
mode: 'subscription',
subscription_data: {
metadata: {
workspaceId,
workspaceId: workspace.id,
plan,
},
...this.getStripeSubscriptionTrialPeriodConfig(
@@ -88,7 +90,7 @@ export class StripeCheckoutService {
async createDirectSubscription({
user,
workspaceId,
workspace,
stripeSubscriptionLineItems,
stripeCustomerId,
plan = BillingPlanKey.PRO,
@@ -96,7 +98,7 @@ export class StripeCheckoutService {
withTrialPeriod,
}: {
user: User;
workspaceId: string;
workspace: Pick<Workspace, 'id' | 'displayName'>;
stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
stripeCustomerId?: string;
plan?: BillingPlanKey;
@@ -107,7 +109,8 @@ export class StripeCheckoutService {
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspaceId,
workspace.id,
workspace.displayName,
);
stripeCustomerId = stripeCustomer.id;
@@ -124,7 +127,7 @@ export class StripeCheckoutService {
customer: stripeCustomerId,
items: subscriptionItems,
metadata: {
workspaceId,
workspaceId: workspace.id,
plan,
},
...this.getStripeSubscriptionTrialPeriodConfig(
@@ -46,8 +46,13 @@ export class StripeCustomerService {
return paymentMethods.length > 0;
}
async createStripeCustomer(userEmail: string, workspaceId: string) {
async createStripeCustomer(
userEmail: string,
workspaceId: string,
customerName: string | undefined,
) {
const customer = await this.stripe.customers.create({
name: customerName,
email: userEmail,
metadata: {
workspaceId,
@@ -83,4 +83,16 @@ export class StripeSubscriptionService {
): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.update(stripeSubscriptionId, updateData);
}
async setYearlyThresholds(stripeSubscriptionId: string) {
return this.stripe.subscriptions.update(stripeSubscriptionId, {
billing_thresholds: {
amount_gte:
this.twentyConfigService.get(
'BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT',
) * 12,
reset_billing_cycle_anchor: false,
},
});
}
}
@@ -0,0 +1,16 @@
export type MeterBillingPriceTiers = [
{
up_to: number;
flat_amount: number;
unit_amount: number;
flat_amount_decimal: string;
unit_amount_decimal: string;
},
{
up_to: null;
flat_amount: null;
unit_amount: null;
flat_amount_decimal: null;
unit_amount_decimal: string;
},
];
@@ -40,6 +40,7 @@ export const transformStripePriceToDatabasePrice = (data: Stripe.Price) => {
? getBillingPriceTiersMode(data.tiers_mode)
: undefined,
recurring: data.recurring === null ? undefined : data.recurring,
metadata: data.metadata,
};
};
@@ -7,8 +7,6 @@ import {
type CoreMessage,
type CoreUserMessage,
type FilePart,
generateObject,
generateText,
type ImagePart,
streamText,
ToolSet,
@@ -30,11 +28,10 @@ import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
import { AgentEntity } from './agent.entity';
@@ -61,6 +58,7 @@ export class AgentExecutionService {
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentToolGeneratorService: AgentToolGeneratorService,
private readonly aiBillingService: AIBillingService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(FileEntity)
@@ -301,66 +299,18 @@ export class AgentExecutionService {
`Sending request to AI model with ${llmMessages.length} messages`,
);
return streamText(aiRequestConfig);
}
const model = await this.aiModelRegistryService.resolveModelForAgent(agent);
async executeAgent({
agent,
schema,
userPrompt,
}: {
agent: AgentEntity | null;
context: Record<string, unknown>;
schema: OutputSchema;
userPrompt: string;
}): Promise<AgentExecutionResult> {
try {
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `You are executing as part of a workflow automation. ${agent ? agent.prompt : ''}`,
agent,
prompt: userPrompt,
});
const textResponse = await generateText(aiRequestConfig);
const stream = streamText(aiRequestConfig);
if (Object.keys(schema).length === 0) {
return {
result: { response: textResponse.text },
usage: textResponse.usage,
};
}
const output = await generateObject({
system: AGENT_SYSTEM_PROMPTS.OUTPUT_GENERATOR,
model: aiRequestConfig.model,
prompt: `Based on the following execution results, generate the structured output according to the schema:
Execution Results: ${textResponse.text}
Please generate the structured output based on the execution results and context above.`,
schema: convertOutputSchemaToZod(schema),
});
return {
result: output.object,
usage: {
promptTokens:
(textResponse.usage?.promptTokens ?? 0) +
(output.usage?.promptTokens ?? 0),
completionTokens:
(textResponse.usage?.completionTokens ?? 0) +
(output.usage?.completionTokens ?? 0),
totalTokens:
(textResponse.usage?.totalTokens ?? 0) +
(output.usage?.totalTokens ?? 0),
},
};
} catch (error) {
if (error instanceof AgentException) {
throw error;
}
throw new AgentException(
error instanceof Error ? error.message : 'Agent execution failed',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
stream.usage.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
model.modelId,
usage,
workspace.id,
);
}
});
return stream;
}
}
@@ -0,0 +1,14 @@
import { isDefined } from 'twenty-shared/utils';
export const findOrThrow = <T>(
array: T[],
predicate: (value: T) => boolean,
): T => {
const result = array.find(predicate);
if (!isDefined(result)) {
throw new Error('Element not found');
}
return result;
};
@@ -331,6 +331,7 @@ export {
IconWebhook,
IconWorld,
IconX,
IconCalendarRepeat,
} from '@tabler/icons-react';
export type { IconProps as TablerIconsProps } from '@tabler/icons-react';
+1
View File
@@ -393,6 +393,7 @@ export {
IconWebhook,
IconWorld,
IconX,
IconCalendarRepeat,
} from './icon/components/TablerIcons';
export { useIcons } from './icon/hooks/useIcons';
export { IconsProvider } from './icon/providers/IconsProvider';