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:
File diff suppressed because one or more lines are too long
@@ -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>;
|
||||
|
||||
+102
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-86
@@ -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>
|
||||
);
|
||||
};
|
||||
+67
-22
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+100
@@ -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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_SUBSCRIPTION_ITEM_PRICE = gql`
|
||||
mutation UpdateSubscriptionItemPrice($priceId: String!) {
|
||||
updateSubscriptionItemPrice(priceId: $priceId) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
+2
-4
@@ -4,11 +4,9 @@ export const GET_METERED_PRODUCTS_USAGE = gql`
|
||||
query GetMeteredProductsUsage {
|
||||
getMeteredProductsUsage {
|
||||
productKey
|
||||
usageQuantity
|
||||
freeTierQuantity
|
||||
freeTrialQuantity
|
||||
usedCredits
|
||||
grantedCredits
|
||||
unitPriceCents
|
||||
totalCostCents
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const LIST_AVAILABLE_METERED_BILLING_PRICES = gql`
|
||||
query listAvailableMeteredBillingPrices {
|
||||
listAvailableMeteredBillingPrices {
|
||||
nickname
|
||||
amount
|
||||
stripePriceId
|
||||
recurringInterval
|
||||
}
|
||||
}
|
||||
`;
|
||||
+4
-25
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+16
@@ -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;
|
||||
};
|
||||
+45
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -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,
|
||||
};
|
||||
+13
@@ -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;
|
||||
}
|
||||
+2
-8
@@ -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;
|
||||
}
|
||||
|
||||
+20
@@ -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;
|
||||
}
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -102,6 +102,7 @@ export class BillingSubscription {
|
||||
@Column({ nullable: false, default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: 'timestamptz',
|
||||
|
||||
+443
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+49
-5
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+113
-27
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+446
@@ -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 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+118
-19
@@ -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 },
|
||||
|
||||
+5
-9
@@ -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,
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
-8
@@ -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(
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+12
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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;
|
||||
},
|
||||
];
|
||||
+1
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+12
-62
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user