Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0c2ea075 | ||
|
|
a7056c9b8e | ||
|
|
036cf249b3 | ||
|
|
7bf3449442 | ||
|
|
a5d140d8d7 | ||
|
|
70c516e92f | ||
|
|
f18cf8bcc9 | ||
|
|
43d6d141aa | ||
|
|
322c8f9cbf | ||
|
|
d083491b7c | ||
|
|
db2818b9d3 | ||
|
|
6e680eaff6 | ||
|
|
664e37717c | ||
|
|
2b61fa5717 | ||
|
|
c07c62e292 | ||
|
|
8d4da7b2fa | ||
|
|
d6a21b6d71 | ||
|
|
2206a17be3 |
@@ -580,6 +580,23 @@ export type Billing = {
|
||||
trialPeriods: Array<BillingTrialPeriod>;
|
||||
};
|
||||
|
||||
export type BillingAnalytics = {
|
||||
__typename?: 'BillingAnalytics';
|
||||
periodEnd: Scalars['DateTime'];
|
||||
periodStart: Scalars['DateTime'];
|
||||
timeSeries: Array<BillingUsageTimeSeries>;
|
||||
usageByExecutionType: Array<BillingUsageBreakdownItem>;
|
||||
usageByResource: Array<BillingUsageBreakdownItem>;
|
||||
usageByUser: Array<BillingUsageBreakdownItem>;
|
||||
userDailyUsage?: Maybe<BillingUserDailyUsage>;
|
||||
};
|
||||
|
||||
export type BillingAnalyticsInput = {
|
||||
periodEnd?: InputMaybe<Scalars['DateTime']>;
|
||||
periodStart?: InputMaybe<Scalars['DateTime']>;
|
||||
userWorkspaceId?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type BillingEndTrialPeriod = {
|
||||
__typename?: 'BillingEndTrialPeriod';
|
||||
/** Billing portal URL for payment method update (returned when no payment method exists) */
|
||||
@@ -749,11 +766,30 @@ export type BillingUpdate = {
|
||||
currentBillingSubscription: BillingSubscription;
|
||||
};
|
||||
|
||||
export type BillingUsageBreakdownItem = {
|
||||
__typename?: 'BillingUsageBreakdownItem';
|
||||
creditsUsed: Scalars['Float'];
|
||||
key: Scalars['String'];
|
||||
label?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type BillingUsageTimeSeries = {
|
||||
__typename?: 'BillingUsageTimeSeries';
|
||||
creditsUsed: Scalars['Float'];
|
||||
date: Scalars['String'];
|
||||
};
|
||||
|
||||
export enum BillingUsageType {
|
||||
LICENSED = 'LICENSED',
|
||||
METERED = 'METERED'
|
||||
}
|
||||
|
||||
export type BillingUserDailyUsage = {
|
||||
__typename?: 'BillingUserDailyUsage';
|
||||
dailyUsage: Array<BillingUsageTimeSeries>;
|
||||
userWorkspaceId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type BooleanFieldComparison = {
|
||||
is?: InputMaybe<Scalars['Boolean']>;
|
||||
isNot?: InputMaybe<Scalars['Boolean']>;
|
||||
@@ -1588,6 +1624,7 @@ export type EventLogRecord = {
|
||||
};
|
||||
|
||||
export enum EventLogTable {
|
||||
BILLING_EVENT = 'BILLING_EVENT',
|
||||
OBJECT_EVENT = 'OBJECT_EVENT',
|
||||
PAGEVIEW = 'PAGEVIEW',
|
||||
WORKSPACE_EVENT = 'WORKSPACE_EVENT'
|
||||
@@ -1633,7 +1670,8 @@ export enum FeatureFlagKey {
|
||||
IS_RICH_TEXT_V1_MIGRATED = 'IS_RICH_TEXT_V1_MIGRATED',
|
||||
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED',
|
||||
IS_TASK_TARGET_MIGRATED = 'IS_TASK_TARGET_MIGRATED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
IS_USAGE_ANALYTICS_ENABLED = 'IS_USAGE_ANALYTICS_ENABLED'
|
||||
}
|
||||
|
||||
export type Field = {
|
||||
@@ -4000,6 +4038,7 @@ export type Query = {
|
||||
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
|
||||
getAutoCompleteAddress: Array<AutocompleteResult>;
|
||||
getAvailablePackages: Scalars['JSON'];
|
||||
getBillingAnalytics: BillingAnalytics;
|
||||
getConfigVariablesGrouped: ConfigVariables;
|
||||
getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount;
|
||||
getDatabaseConfigVariable: ConfigVariable;
|
||||
@@ -4215,6 +4254,11 @@ export type QueryGetAvailablePackagesArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetBillingAnalyticsArgs = {
|
||||
input?: InputMaybe<BillingAnalyticsInput>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetConnectedImapSmtpCaldavAccountArgs = {
|
||||
id: Scalars['UUID'];
|
||||
};
|
||||
@@ -6295,6 +6339,13 @@ export type BillingPortalSessionQueryVariables = Exact<{
|
||||
|
||||
export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSession: { __typename?: 'BillingSession', url?: string | null } };
|
||||
|
||||
export type GetBillingAnalyticsQueryVariables = Exact<{
|
||||
input?: InputMaybe<BillingAnalyticsInput>;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetBillingAnalyticsQuery = { __typename?: 'Query', getBillingAnalytics: { __typename?: 'BillingAnalytics', periodStart: string, periodEnd: string, usageByUser: Array<{ __typename?: 'BillingUsageBreakdownItem', key: string, label?: string | null, creditsUsed: number }>, usageByResource: Array<{ __typename?: 'BillingUsageBreakdownItem', key: string, label?: string | null, creditsUsed: number }>, usageByExecutionType: Array<{ __typename?: 'BillingUsageBreakdownItem', key: string, creditsUsed: number }>, timeSeries: Array<{ __typename?: 'BillingUsageTimeSeries', date: string, creditsUsed: number }>, userDailyUsage?: { __typename?: 'BillingUserDailyUsage', userWorkspaceId: string, dailyUsage: Array<{ __typename?: 'BillingUsageTimeSeries', date: string, creditsUsed: number }> } | null } };
|
||||
|
||||
export type GetMeteredProductsUsageQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -7682,6 +7733,7 @@ export const SetMeteredSubscriptionPriceDocument = {"kind":"Document","definitio
|
||||
export const SwitchBillingPlanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SwitchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"switchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<SwitchBillingPlanMutation, SwitchBillingPlanMutationVariables>;
|
||||
export const SwitchSubscriptionIntervalDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SwitchSubscriptionInterval"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"switchSubscriptionInterval"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<SwitchSubscriptionIntervalMutation, SwitchSubscriptionIntervalMutationVariables>;
|
||||
export const BillingPortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BillingPortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billingPortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<BillingPortalSessionQuery, BillingPortalSessionQueryVariables>;
|
||||
export const GetBillingAnalyticsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBillingAnalytics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BillingAnalyticsInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getBillingAnalytics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"usageByUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"usageByResource"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"usageByExecutionType"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"timeSeries"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}},{"kind":"Field","name":{"kind":"Name","value":"periodStart"}},{"kind":"Field","name":{"kind":"Name","value":"periodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"userDailyUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"dailyUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"creditsUsed"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetBillingAnalyticsQuery, GetBillingAnalyticsQueryVariables>;
|
||||
export const GetMeteredProductsUsageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMeteredProductsUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getMeteredProductsUsage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"usedCredits"}},{"kind":"Field","name":{"kind":"Name","value":"grantedCredits"}},{"kind":"Field","name":{"kind":"Name","value":"rolloverCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalGrantedCredits"}},{"kind":"Field","name":{"kind":"Name","value":"unitPriceCents"}}]}}]}}]} as unknown as DocumentNode<GetMeteredProductsUsageQuery, GetMeteredProductsUsageQueryVariables>;
|
||||
export const ListPlansDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"listPlans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"listPlans"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"licensedProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingLicensedProduct"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"prices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingPriceLicensedFragment"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"meteredProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingMeteredProduct"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"prices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingPriceMeteredFragment"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceLicensedFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceLicensed"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceMeteredFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceMetered"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"tiers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flatAmount"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"upTo"}}]}}]}}]} as unknown as DocumentNode<ListPlansQuery, ListPlansQueryVariables>;
|
||||
export const FindManyCommandMenuItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyCommandMenuItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commandMenuItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CommandMenuItemFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CommandMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CommandMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workflowVersionId"}},{"kind":"Field","name":{"kind":"Name","value":"frontComponentId"}},{"kind":"Field","name":{"kind":"Name","value":"frontComponent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isHeadless"}}]}},{"kind":"Field","name":{"kind":"Name","value":"engineComponentKey"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"shortLabel"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"isPinned"}},{"kind":"Field","name":{"kind":"Name","value":"hotKeys"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalAvailabilityExpression"}},{"kind":"Field","name":{"kind":"Name","value":"availabilityType"}},{"kind":"Field","name":{"kind":"Name","value":"availabilityObjectMetadataId"}}]}}]} as unknown as DocumentNode<FindManyCommandMenuItemsQuery, FindManyCommandMenuItemsQueryVariables>;
|
||||
|
||||
@@ -251,6 +251,18 @@ const SettingsBilling = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsUsage = lazy(() =>
|
||||
import('~/pages/settings/SettingsUsage').then((module) => ({
|
||||
default: module.SettingsUsage,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsUsageUserDetail = lazy(() =>
|
||||
import('~/pages/settings/SettingsUsageUserDetail').then((module) => ({
|
||||
default: module.SettingsUsageUserDetail,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsObjects = lazy(() =>
|
||||
import('~/pages/settings/data-model/SettingsObjects').then((module) => ({
|
||||
default: module.SettingsObjects,
|
||||
@@ -497,6 +509,19 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
|
||||
element={<SettingsLogicFunctionDetail />}
|
||||
/>
|
||||
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
|
||||
<Route
|
||||
element={
|
||||
<SettingsProtectedRouteWrapper
|
||||
requiredFeatureFlag={FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
|
||||
<Route
|
||||
path={SettingsPath.UsageUserDetail}
|
||||
element={<SettingsUsageUserDetail />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
|
||||
<Route
|
||||
path={SettingsPath.NewEmailingDomain}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
export const StyledBillingLineChartContainer = styled.div`
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const StyledBillingPieChartContainer = styled.div`
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledTooltip = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-shadow: ${themeCssVariables.boxShadow.light};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
export const BillingChartTooltip = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) => (
|
||||
<StyledTooltip>
|
||||
<strong>{label}</strong>: {value}
|
||||
</StyledTooltip>
|
||||
);
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
import { BillingChartTooltip } from '@/billing/components/BillingChartTooltip';
|
||||
import {
|
||||
StyledBillingLineChartContainer,
|
||||
StyledBillingPieChartContainer,
|
||||
} from '@/billing/components/BillingChartContainers';
|
||||
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
import {
|
||||
type PeriodPreset,
|
||||
getChartColors,
|
||||
getExecutionTypeLabel,
|
||||
getPeriodDates,
|
||||
getPeriodOptions,
|
||||
} from '@/billing/utils/billingAnalyticsUtils';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig';
|
||||
import { useLineChartTheme } from '@/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartTheme';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { ResponsiveLine } from '@nivo/line';
|
||||
import { ResponsivePie } from '@nivo/pie';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { Avatar, H2Title, IconChevronRight } from 'twenty-ui/display';
|
||||
import { ProgressBar } from 'twenty-ui/feedback';
|
||||
import { SearchInput } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { GetBillingAnalyticsDocument } from '~/generated-metadata/graphql';
|
||||
import { formatDate } from '~/utils/date-utils';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
|
||||
const StyledBarRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledBarLabel = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledLabelText = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledValueText = styled.span`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledSearchInputContainer = styled.div`
|
||||
padding-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledIconChevronRightContainer = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
`;
|
||||
|
||||
const USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS = '1fr 120px 36px';
|
||||
|
||||
export const SettingsBillingAnalyticsSection = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatNumber } = useNumberFormat();
|
||||
|
||||
const [typePeriod, setTypePeriod] = useState<PeriodPreset>('30d');
|
||||
const [dailyPeriod, setDailyPeriod] = useState<PeriodPreset>('30d');
|
||||
const [userPeriod, setUserPeriod] = useState<PeriodPreset>('30d');
|
||||
const [resourcePeriod, setResourcePeriod] = useState<PeriodPreset>('30d');
|
||||
const [userSearchTerm, setUserSearchTerm] = useState('');
|
||||
|
||||
const chartColors = getChartColors(theme);
|
||||
const lineChartTheme = useLineChartTheme();
|
||||
const periodOptions = getPeriodOptions();
|
||||
|
||||
const typeDates = useMemo(() => getPeriodDates(typePeriod), [typePeriod]);
|
||||
const dailyDates = useMemo(() => getPeriodDates(dailyPeriod), [dailyPeriod]);
|
||||
const userDates = useMemo(() => getPeriodDates(userPeriod), [userPeriod]);
|
||||
const resourceDates = useMemo(
|
||||
() => getPeriodDates(resourcePeriod),
|
||||
[resourcePeriod],
|
||||
);
|
||||
|
||||
const { data: typeData, loading: typeLoading } = useQuery(
|
||||
GetBillingAnalyticsDocument,
|
||||
{ variables: { input: typeDates } },
|
||||
);
|
||||
|
||||
const { data: dailyData, loading: dailyLoading } = useQuery(
|
||||
GetBillingAnalyticsDocument,
|
||||
{ variables: { input: dailyDates } },
|
||||
);
|
||||
|
||||
const { data: userData, loading: userLoading } = useQuery(
|
||||
GetBillingAnalyticsDocument,
|
||||
{ variables: { input: userDates } },
|
||||
);
|
||||
|
||||
const { data: resourceData, loading: resourceLoading } = useQuery(
|
||||
GetBillingAnalyticsDocument,
|
||||
{ variables: { input: resourceDates } },
|
||||
);
|
||||
|
||||
const typeAnalytics = typeData?.getBillingAnalytics;
|
||||
const dailyAnalytics = dailyData?.getBillingAnalytics;
|
||||
const userAnalytics = userData?.getBillingAnalytics;
|
||||
const resourceAnalytics = resourceData?.getBillingAnalytics;
|
||||
|
||||
const usageByExecutionType = typeAnalytics?.usageByExecutionType ?? [];
|
||||
const timeSeries = dailyAnalytics?.timeSeries ?? [];
|
||||
const usageByUser = userAnalytics?.usageByUser ?? [];
|
||||
const usageByResource = resourceAnalytics?.usageByResource ?? [];
|
||||
|
||||
const allLoading =
|
||||
typeLoading && dailyLoading && userLoading && resourceLoading;
|
||||
|
||||
if (allLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyData =
|
||||
usageByExecutionType.length > 0 ||
|
||||
timeSeries.length > 0 ||
|
||||
usageByUser.length > 0 ||
|
||||
usageByResource.length > 0;
|
||||
|
||||
const totalCredits = usageByExecutionType.reduce(
|
||||
(sum, item) => sum + item.creditsUsed,
|
||||
0,
|
||||
);
|
||||
|
||||
const resourceTotal = usageByResource.reduce(
|
||||
(sum, resource) => sum + resource.creditsUsed,
|
||||
0,
|
||||
);
|
||||
|
||||
const filteredUsageByUser = usageByUser.filter((item) => {
|
||||
const search = normalizeSearchText(userSearchTerm);
|
||||
const name = normalizeSearchText(item.label ?? item.key);
|
||||
|
||||
return name.includes(search);
|
||||
});
|
||||
|
||||
const pieData = usageByExecutionType.map((item, index) => ({
|
||||
id: getExecutionTypeLabel(item.key),
|
||||
value: item.creditsUsed,
|
||||
color: chartColors[index % chartColors.length],
|
||||
}));
|
||||
|
||||
const lineData = [
|
||||
{
|
||||
id: 'credits',
|
||||
data: timeSeries.map((point) => ({
|
||||
x: formatDate(point.date, 'MMM d'),
|
||||
y: point.creditsUsed,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasAnyData) {
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage Analytics`}
|
||||
description={t`Credit usage breakdown for your workspace.`}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`No usage data`}
|
||||
value={t`No credit consumption recorded yet.`}
|
||||
/>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{usageByExecutionType.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage by Type`}
|
||||
description={`${formatNumber(totalCredits)} ${t`credits`}`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-type-period"
|
||||
value={typePeriod}
|
||||
options={periodOptions}
|
||||
onChange={setTypePeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledBillingPieChartContainer>
|
||||
<ResponsivePie
|
||||
data={pieData}
|
||||
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
|
||||
innerRadius={0.6}
|
||||
padAngle={0.5}
|
||||
cornerRadius={2}
|
||||
colors={pieData.map((item) => item.color)}
|
||||
enableArcLabels={false}
|
||||
enableArcLinkLabels={true}
|
||||
arcLinkLabelsSkipAngle={10}
|
||||
arcLinkLabelsTextColor={theme.font.color.secondary}
|
||||
arcLinkLabelsColor={{ from: 'color' }}
|
||||
arcLinkLabelsDiagonalLength={10}
|
||||
arcLinkLabelsStraightLength={10}
|
||||
animate
|
||||
motionConfig={CHART_MOTION_CONFIG}
|
||||
tooltip={({ datum }) => (
|
||||
<BillingChartTooltip
|
||||
label={String(datum.id)}
|
||||
value={`${formatNumber(datum.value)} ${t`credits`}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledBillingPieChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{timeSeries.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Daily Usage`}
|
||||
description={t`Credit consumption over time.`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-daily-period"
|
||||
value={dailyPeriod}
|
||||
options={periodOptions}
|
||||
onChange={setDailyPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledBillingLineChartContainer>
|
||||
<ResponsiveLine
|
||||
data={lineData}
|
||||
margin={{ top: 10, right: 20, bottom: 30, left: 50 }}
|
||||
xScale={{ type: 'point' }}
|
||||
yScale={{
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: 'auto',
|
||||
}}
|
||||
curve="monotoneX"
|
||||
lineWidth={2}
|
||||
colors={[theme.color.blue]}
|
||||
enablePoints={true}
|
||||
pointSize={6}
|
||||
pointColor={theme.background.primary}
|
||||
pointBorderWidth={2}
|
||||
pointBorderColor={theme.color.blue}
|
||||
enableArea={true}
|
||||
areaOpacity={0.1}
|
||||
enableGridX={false}
|
||||
enableGridY={true}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
tickRotation: timeSeries.length > 14 ? -45 : 0,
|
||||
tickValues:
|
||||
timeSeries.length > 10
|
||||
? lineData[0].data
|
||||
.filter(
|
||||
(_, index) =>
|
||||
index % Math.ceil(timeSeries.length / 7) === 0,
|
||||
)
|
||||
.map((point) => point.x)
|
||||
: undefined,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
tickValues: 5,
|
||||
}}
|
||||
animate
|
||||
motionConfig={CHART_MOTION_CONFIG}
|
||||
theme={lineChartTheme}
|
||||
enableSlices="x"
|
||||
sliceTooltip={({ slice }) => (
|
||||
<BillingChartTooltip
|
||||
label={String(slice.points[0]?.data.xFormatted)}
|
||||
value={`${formatNumber(Number(slice.points[0]?.data.yFormatted))} ${t`credits`}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledBillingLineChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{usageByUser.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage by User`}
|
||||
description={t`Click a user to see their daily breakdown.`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-user-period"
|
||||
value={userPeriod}
|
||||
options={periodOptions}
|
||||
onChange={setUserPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StyledSearchInputContainer>
|
||||
<SearchInput
|
||||
placeholder={t`Search for a user...`}
|
||||
value={userSearchTerm}
|
||||
onChange={setUserSearchTerm}
|
||||
/>
|
||||
</StyledSearchInputContainer>
|
||||
<Table>
|
||||
<TableRow
|
||||
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
|
||||
>
|
||||
<TableHeader>{t`Name`}</TableHeader>
|
||||
<TableHeader align="right">{t`Credits`}</TableHeader>
|
||||
<TableHeader />
|
||||
</TableRow>
|
||||
{filteredUsageByUser.map((item) => (
|
||||
<TableRow
|
||||
key={item.key}
|
||||
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
|
||||
to={getSettingsPath(SettingsPath.UsageUserDetail, {
|
||||
userWorkspaceId: item.key,
|
||||
})}
|
||||
>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.primary}
|
||||
gap={themeCssVariables.spacing[2]}
|
||||
>
|
||||
<Avatar
|
||||
type="rounded"
|
||||
size="md"
|
||||
placeholder={item.label ?? item.key}
|
||||
placeholderColorSeed={item.key}
|
||||
/>
|
||||
{item.label ?? item.key}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatNumber(item.creditsUsed)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<StyledIconChevronRightContainer>
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledIconChevronRightContainer>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{usageByResource.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage by Resource`}
|
||||
description={t`Credit consumption per agent or workflow.`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-resource-period"
|
||||
value={resourcePeriod}
|
||||
options={periodOptions}
|
||||
onChange={setResourcePeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
{usageByResource.map((item, index) => {
|
||||
const percentage =
|
||||
resourceTotal > 0
|
||||
? (item.creditsUsed / resourceTotal) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<StyledBarRow key={item.key}>
|
||||
<StyledBarLabel>
|
||||
<StyledLabelText>{item.label ?? item.key}</StyledLabelText>
|
||||
<StyledValueText>
|
||||
{formatNumber(item.creditsUsed)} {t`credits`}
|
||||
</StyledValueText>
|
||||
</StyledBarLabel>
|
||||
<ProgressBar
|
||||
value={percentage < 3 && percentage > 0 ? 3 : percentage}
|
||||
barColor={chartColors[index % chartColors.length]}
|
||||
backgroundColor={theme.background.tertiary}
|
||||
withBorderRadius
|
||||
/>
|
||||
</StyledBarRow>
|
||||
);
|
||||
})}
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,32 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { SettingsBillingAnalyticsSection } from '@/billing/components/SettingsBillingAnalyticsSection';
|
||||
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';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title, IconCircleX, IconCreditCard } from 'twenty-ui/display';
|
||||
import { isDefined, getSettingsPath } from 'twenty-shared/utils';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import {
|
||||
H2Title,
|
||||
IconChartBar,
|
||||
IconCircleX,
|
||||
IconCreditCard,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import {
|
||||
SubscriptionStatus,
|
||||
BillingPortalSessionDocument,
|
||||
FeatureFlagKey,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
|
||||
export const SettingsBillingContent = () => {
|
||||
const { t } = useLingui();
|
||||
@@ -45,6 +55,10 @@ export const SettingsBillingContent = () => {
|
||||
skip: !hasSubscriptions,
|
||||
});
|
||||
|
||||
const isUsageAnalyticsEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
|
||||
);
|
||||
|
||||
const billingPortalButtonDisabled =
|
||||
loading || !isDefined(data) || !isDefined(data.billingPortalSession.url);
|
||||
|
||||
@@ -76,6 +90,24 @@ export const SettingsBillingContent = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isUsageAnalyticsEnabled && (
|
||||
<>
|
||||
<SettingsBillingAnalyticsSection />
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage`}
|
||||
description={t`View detailed usage analytics for your workspace`}
|
||||
/>
|
||||
<Link to={getSettingsPath(SettingsPath.Usage)}>
|
||||
<Button
|
||||
Icon={IconChartBar}
|
||||
title={t`View usage`}
|
||||
variant="secondary"
|
||||
/>
|
||||
</Link>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Manage billing information`}
|
||||
|
||||
@@ -5,6 +5,7 @@ const StyledSubscriptionInfoContainer = styled.div`
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_BILLING_ANALYTICS = gql`
|
||||
query GetBillingAnalytics($input: BillingAnalyticsInput) {
|
||||
getBillingAnalytics(input: $input) {
|
||||
usageByUser {
|
||||
key
|
||||
label
|
||||
creditsUsed
|
||||
}
|
||||
usageByResource {
|
||||
key
|
||||
label
|
||||
creditsUsed
|
||||
}
|
||||
usageByExecutionType {
|
||||
key
|
||||
creditsUsed
|
||||
}
|
||||
timeSeries {
|
||||
date
|
||||
creditsUsed
|
||||
}
|
||||
periodStart
|
||||
periodEnd
|
||||
userDailyUsage {
|
||||
userWorkspaceId
|
||||
dailyUsage {
|
||||
date
|
||||
creditsUsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
export type PeriodPreset = '7d' | '30d' | '90d';
|
||||
|
||||
const PERIOD_DAYS: Record<PeriodPreset, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
};
|
||||
|
||||
export const getExecutionTypeLabel = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'ai_token':
|
||||
return t`AI Chat`;
|
||||
case 'workflow_execution':
|
||||
return t`Workflow Execution`;
|
||||
case 'code_execution':
|
||||
return t`Code Execution`;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPeriodOptions = (): {
|
||||
value: PeriodPreset;
|
||||
label: string;
|
||||
}[] => [
|
||||
{ value: '7d', label: t`Last 7 days` },
|
||||
{ value: '30d', label: t`Last 30 days` },
|
||||
{ value: '90d', label: t`Last 90 days` },
|
||||
];
|
||||
|
||||
export const getPeriodDates = (
|
||||
preset: PeriodPreset,
|
||||
): { periodStart: string; periodEnd: string } => {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
|
||||
start.setDate(start.getDate() - PERIOD_DAYS[preset]);
|
||||
|
||||
return {
|
||||
periodStart: start.toISOString(),
|
||||
periodEnd: now.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const CHART_COLORS_KEYS = [
|
||||
'blue',
|
||||
'purple',
|
||||
'turquoise',
|
||||
'orange',
|
||||
'pink',
|
||||
'green',
|
||||
] as const;
|
||||
|
||||
export const getChartColors = (theme: {
|
||||
color: Record<(typeof CHART_COLORS_KEYS)[number], string>;
|
||||
}): string[] => CHART_COLORS_KEYS.map((key) => theme.color[key]);
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IconCalendarEvent,
|
||||
IconColorSwatch,
|
||||
type IconComponent,
|
||||
IconChartBar,
|
||||
IconCurrencyDollar,
|
||||
IconDoorEnter,
|
||||
IconHelpCircle,
|
||||
@@ -76,6 +77,9 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
const isApplicationEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_APPLICATION_ENABLED,
|
||||
);
|
||||
const isUsageAnalyticsEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
|
||||
);
|
||||
const isSupportChatConfigured =
|
||||
supportChat?.supportDriver === 'FRONT' &&
|
||||
isNonEmptyString(supportChat.supportFrontChatId);
|
||||
@@ -159,6 +163,15 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
isHidden:
|
||||
!isBillingEnabled || !permissionMap[PermissionFlagType.WORKSPACE],
|
||||
},
|
||||
{
|
||||
label: t`Usage`,
|
||||
path: SettingsPath.Usage,
|
||||
Icon: IconChartBar,
|
||||
isHidden:
|
||||
!isUsageAnalyticsEnabled ||
|
||||
isBillingEnabled ||
|
||||
!permissionMap[PermissionFlagType.WORKSPACE],
|
||||
},
|
||||
{
|
||||
label: t`APIs & Webhooks`,
|
||||
path: SettingsPath.ApiWebhooks,
|
||||
|
||||
@@ -18,7 +18,7 @@ type TableCellProps = {
|
||||
const StyledTableCell = styled.div<TableCellProps>`
|
||||
align-items: center;
|
||||
color: ${({ color }) => color || themeCssVariables.font.color.secondary};
|
||||
cursor: ${({ clickable }) => (clickable === true ? 'pointer' : 'default')};
|
||||
cursor: ${({ clickable }) => (clickable === true ? 'pointer' : 'inherit')};
|
||||
display: flex;
|
||||
gap: ${({ gap }) => gap ?? 'normal'};
|
||||
height: ${({ height }) => height ?? themeCssVariables.spacing[8]};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { SettingsBillingAnalyticsSection } from '@/billing/components/SettingsBillingAnalyticsSection';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
|
||||
export const SettingsUsage = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Usage`}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{ children: <Trans>Usage</Trans> },
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SettingsBillingAnalyticsSection />
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
import { BillingChartTooltip } from '@/billing/components/BillingChartTooltip';
|
||||
import {
|
||||
StyledBillingLineChartContainer,
|
||||
StyledBillingPieChartContainer,
|
||||
} from '@/billing/components/BillingChartContainers';
|
||||
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
import {
|
||||
type PeriodPreset,
|
||||
getChartColors,
|
||||
getExecutionTypeLabel,
|
||||
getPeriodDates,
|
||||
getPeriodOptions,
|
||||
} from '@/billing/utils/billingAnalyticsUtils';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig';
|
||||
import { useLineChartTheme } from '@/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartTheme';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { ResponsiveLine } from '@nivo/line';
|
||||
import { ResponsivePie } from '@nivo/pie';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { Avatar, H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { GetBillingAnalyticsDocument } from '~/generated-metadata/graphql';
|
||||
import { formatDate } from '~/utils/date-utils';
|
||||
|
||||
const StyledUserHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledUserInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledUserName = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
const StyledUserCredits = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
export const SettingsUsageUserDetail = () => {
|
||||
const { t: tLingui } = useLingui();
|
||||
const { userWorkspaceId } = useParams<{ userWorkspaceId: string }>();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatNumber } = useNumberFormat();
|
||||
const lineChartTheme = useLineChartTheme();
|
||||
const chartColors = getChartColors(theme);
|
||||
|
||||
const [dailyPeriod, setDailyPeriod] = useState<PeriodPreset>('30d');
|
||||
const [typePeriod, setTypePeriod] = useState<PeriodPreset>('30d');
|
||||
|
||||
const periodOptions = getPeriodOptions();
|
||||
|
||||
const dailyDates = useMemo(() => getPeriodDates(dailyPeriod), [dailyPeriod]);
|
||||
const typeDates = useMemo(() => getPeriodDates(typePeriod), [typePeriod]);
|
||||
|
||||
const { data: dailyData, loading: dailyLoading } = useQuery(
|
||||
GetBillingAnalyticsDocument,
|
||||
{
|
||||
variables: {
|
||||
input: {
|
||||
...dailyDates,
|
||||
userWorkspaceId,
|
||||
},
|
||||
},
|
||||
skip: !userWorkspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: typeData, loading: typeLoading } = useQuery(
|
||||
GetBillingAnalyticsDocument,
|
||||
{
|
||||
variables: {
|
||||
input: {
|
||||
...typeDates,
|
||||
userWorkspaceId,
|
||||
},
|
||||
},
|
||||
skip: !userWorkspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const dailyAnalytics = dailyData?.getBillingAnalytics;
|
||||
const typeAnalytics = typeData?.getBillingAnalytics;
|
||||
|
||||
const userDailyUsage = dailyAnalytics?.userDailyUsage?.dailyUsage ?? [];
|
||||
const usageByExecutionType = typeAnalytics?.usageByExecutionType ?? [];
|
||||
|
||||
const userName =
|
||||
dailyAnalytics?.usageByUser?.find((item) => item.key === userWorkspaceId)
|
||||
?.label ??
|
||||
typeAnalytics?.usageByUser?.find((item) => item.key === userWorkspaceId)
|
||||
?.label;
|
||||
|
||||
const totalCredits = usageByExecutionType.reduce(
|
||||
(sum, item) => sum + item.creditsUsed,
|
||||
0,
|
||||
);
|
||||
|
||||
const displayName = userName ?? userWorkspaceId ?? '';
|
||||
|
||||
const pieData = usageByExecutionType.map((item, index) => ({
|
||||
id: getExecutionTypeLabel(item.key),
|
||||
value: item.creditsUsed,
|
||||
color: chartColors[index % chartColors.length],
|
||||
}));
|
||||
|
||||
const lineData = [
|
||||
{
|
||||
id: 'credits',
|
||||
data: userDailyUsage.map((point) => ({
|
||||
x: formatDate(point.date, 'MMM d'),
|
||||
y: point.creditsUsed,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const isInitialLoading =
|
||||
dailyLoading && typeLoading && !dailyData && !typeData;
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: <Trans>Usage</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Usage),
|
||||
},
|
||||
{ children: isInitialLoading ? '' : displayName },
|
||||
];
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={tLingui`User Usage`}
|
||||
links={breadcrumbLinks}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={4}
|
||||
>
|
||||
<StyledUserHeader>
|
||||
<Skeleton width={40} height={40} borderRadius={8} />
|
||||
<StyledUserInfo>
|
||||
<Skeleton width={160} height={16} />
|
||||
<Skeleton width={100} height={13} />
|
||||
</StyledUserInfo>
|
||||
</StyledUserHeader>
|
||||
<Section>
|
||||
<Skeleton width={120} height={16} />
|
||||
<Skeleton
|
||||
width="100%"
|
||||
height={200}
|
||||
borderRadius={8}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Skeleton width={120} height={16} />
|
||||
<Skeleton
|
||||
width="100%"
|
||||
height={220}
|
||||
borderRadius={8}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</Section>
|
||||
</SkeletonTheme>
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer title={tLingui`User Usage`} links={breadcrumbLinks}>
|
||||
<SettingsPageContainer>
|
||||
<StyledUserHeader>
|
||||
<Avatar
|
||||
type="rounded"
|
||||
size="xl"
|
||||
placeholder={displayName}
|
||||
placeholderColorSeed={userWorkspaceId}
|
||||
/>
|
||||
<StyledUserInfo>
|
||||
<StyledUserName>{displayName}</StyledUserName>
|
||||
<StyledUserCredits>
|
||||
{formatNumber(totalCredits)} {t`credits used`}
|
||||
</StyledUserCredits>
|
||||
</StyledUserInfo>
|
||||
</StyledUserHeader>
|
||||
|
||||
{userDailyUsage.length === 0 && pieData.length === 0 && (
|
||||
<Section>
|
||||
<SubscriptionInfoContainer>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`No usage data`}
|
||||
value={t`No credit consumption recorded for this user.`}
|
||||
/>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{userDailyUsage.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Daily Usage`}
|
||||
description={t`Per-day credit consumption.`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="user-daily-period"
|
||||
value={dailyPeriod}
|
||||
options={periodOptions}
|
||||
onChange={setDailyPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledBillingLineChartContainer>
|
||||
<ResponsiveLine
|
||||
data={lineData}
|
||||
margin={{ top: 10, right: 20, bottom: 30, left: 50 }}
|
||||
xScale={{ type: 'point' }}
|
||||
yScale={{
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: 'auto',
|
||||
}}
|
||||
curve="monotoneX"
|
||||
lineWidth={2}
|
||||
colors={[theme.color.blue]}
|
||||
enablePoints={true}
|
||||
pointSize={6}
|
||||
pointColor={theme.background.primary}
|
||||
pointBorderWidth={2}
|
||||
pointBorderColor={theme.color.blue}
|
||||
enableArea={true}
|
||||
areaOpacity={0.1}
|
||||
enableGridX={false}
|
||||
enableGridY={true}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
tickRotation: userDailyUsage.length > 14 ? -45 : 0,
|
||||
tickValues:
|
||||
userDailyUsage.length > 10
|
||||
? lineData[0].data
|
||||
.filter(
|
||||
(_, index) =>
|
||||
index % Math.ceil(userDailyUsage.length / 7) ===
|
||||
0,
|
||||
)
|
||||
.map((point) => point.x)
|
||||
: undefined,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
tickValues: 5,
|
||||
}}
|
||||
animate
|
||||
motionConfig={CHART_MOTION_CONFIG}
|
||||
theme={lineChartTheme}
|
||||
enableSlices="x"
|
||||
sliceTooltip={({ slice }) => (
|
||||
<BillingChartTooltip
|
||||
label={String(slice.points[0]?.data.xFormatted)}
|
||||
value={`${formatNumber(Number(slice.points[0]?.data.yFormatted))} ${t`credits`}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledBillingLineChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{usageByExecutionType.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage by Type`}
|
||||
description={`${formatNumber(totalCredits)} ${t`credits`}`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="user-type-period"
|
||||
value={typePeriod}
|
||||
options={periodOptions}
|
||||
onChange={setTypePeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledBillingPieChartContainer>
|
||||
<ResponsivePie
|
||||
data={pieData}
|
||||
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
|
||||
innerRadius={0.6}
|
||||
padAngle={0.5}
|
||||
cornerRadius={2}
|
||||
colors={pieData.map((item) => item.color)}
|
||||
enableArcLabels={false}
|
||||
enableArcLinkLabels={true}
|
||||
arcLinkLabelsSkipAngle={10}
|
||||
arcLinkLabelsTextColor={theme.font.color.secondary}
|
||||
arcLinkLabelsColor={{ from: 'color' }}
|
||||
arcLinkLabelsDiagonalLength={10}
|
||||
arcLinkLabelsStraightLength={10}
|
||||
animate
|
||||
motionConfig={CHART_MOTION_CONFIG}
|
||||
tooltip={({ datum }) => (
|
||||
<BillingChartTooltip
|
||||
label={String(datum.id)}
|
||||
value={`${formatNumber(datum.value)} ${t`credits`}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledBillingPieChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
};
|
||||
+23
-3
@@ -79,6 +79,23 @@ const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const BILLING_EVENT_COLUMNS: ColumnConfig[] = [
|
||||
{ id: 'event', label: msg`Event Type`, minWidth: 100, defaultWidth: 160 },
|
||||
{
|
||||
id: 'timestamp',
|
||||
label: msg`Timestamp`,
|
||||
minWidth: 100,
|
||||
defaultWidth: 140,
|
||||
},
|
||||
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
|
||||
{
|
||||
id: 'properties',
|
||||
label: msg`Details`,
|
||||
minWidth: 200,
|
||||
defaultWidth: 400,
|
||||
},
|
||||
];
|
||||
|
||||
const StyledScrollWrapperContainer = styled.div`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -159,9 +176,12 @@ export const EventLogResultsTable = ({
|
||||
const { t } = useLingui();
|
||||
|
||||
const showObjectEventColumns = selectedTable === EventLogTable.OBJECT_EVENT;
|
||||
const baseColumns = showObjectEventColumns
|
||||
? OBJECT_EVENT_COLUMNS
|
||||
: DEFAULT_COLUMNS;
|
||||
const baseColumns =
|
||||
selectedTable === EventLogTable.OBJECT_EVENT
|
||||
? OBJECT_EVENT_COLUMNS
|
||||
: selectedTable === EventLogTable.BILLING_EVENT
|
||||
? BILLING_EVENT_COLUMNS
|
||||
: DEFAULT_COLUMNS;
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() =>
|
||||
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
|
||||
|
||||
+4
@@ -27,6 +27,10 @@ export const EventLogTableSelector = ({
|
||||
value: EventLogTable.OBJECT_EVENT,
|
||||
label: t`Object Events`,
|
||||
},
|
||||
{
|
||||
value: EventLogTable.BILLING_EVENT,
|
||||
label: t`Billing Events`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1459,6 +1459,32 @@ type BillingUpdate {
|
||||
billingSubscriptions: [BillingSubscription!]!
|
||||
}
|
||||
|
||||
type BillingUsageBreakdownItem {
|
||||
key: String!
|
||||
label: String
|
||||
creditsUsed: Float!
|
||||
}
|
||||
|
||||
type BillingUsageTimeSeries {
|
||||
date: String!
|
||||
creditsUsed: Float!
|
||||
}
|
||||
|
||||
type BillingUserDailyUsage {
|
||||
userWorkspaceId: String!
|
||||
dailyUsage: [BillingUsageTimeSeries!]!
|
||||
}
|
||||
|
||||
type BillingAnalytics {
|
||||
usageByUser: [BillingUsageBreakdownItem!]!
|
||||
usageByResource: [BillingUsageBreakdownItem!]!
|
||||
usageByExecutionType: [BillingUsageBreakdownItem!]!
|
||||
timeSeries: [BillingUsageTimeSeries!]!
|
||||
periodStart: DateTime!
|
||||
periodEnd: DateTime!
|
||||
userDailyUsage: BillingUserDailyUsage
|
||||
}
|
||||
|
||||
type EnterpriseLicenseInfoDTO {
|
||||
isValid: Boolean!
|
||||
licensee: String
|
||||
@@ -1612,6 +1638,7 @@ enum FeatureFlagKey {
|
||||
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED
|
||||
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED
|
||||
IS_DRAFT_EMAIL_ENABLED
|
||||
IS_USAGE_ANALYTICS_ENABLED
|
||||
IS_RICH_TEXT_V1_MIGRATED
|
||||
}
|
||||
|
||||
@@ -2980,6 +3007,7 @@ type Query {
|
||||
billingPortalSession(returnUrlPath: String): BillingSession!
|
||||
listPlans: [BillingPlan!]!
|
||||
getMeteredProductsUsage: [BillingMeteredProductUsage!]!
|
||||
getBillingAnalytics(input: BillingAnalyticsInput): BillingAnalytics!
|
||||
enterprisePortalSession(returnUrlPath: String): String
|
||||
enterpriseCheckoutSession(billingInterval: String): String
|
||||
enterpriseSubscriptionStatus: EnterpriseSubscriptionStatusDTO
|
||||
@@ -3078,6 +3106,12 @@ input AgentIdInput {
|
||||
id: UUID!
|
||||
}
|
||||
|
||||
input BillingAnalyticsInput {
|
||||
periodStart: DateTime
|
||||
periodEnd: DateTime
|
||||
userWorkspaceId: String
|
||||
}
|
||||
|
||||
input GetApiKeyInput {
|
||||
id: UUID!
|
||||
}
|
||||
@@ -3143,6 +3177,7 @@ enum EventLogTable {
|
||||
WORKSPACE_EVENT
|
||||
PAGEVIEW
|
||||
OBJECT_EVENT
|
||||
BILLING_EVENT
|
||||
}
|
||||
|
||||
input EventLogFiltersInput {
|
||||
|
||||
@@ -1177,6 +1177,36 @@ export interface BillingUpdate {
|
||||
__typename: 'BillingUpdate'
|
||||
}
|
||||
|
||||
export interface BillingUsageBreakdownItem {
|
||||
key: Scalars['String']
|
||||
label?: Scalars['String']
|
||||
creditsUsed: Scalars['Float']
|
||||
__typename: 'BillingUsageBreakdownItem'
|
||||
}
|
||||
|
||||
export interface BillingUsageTimeSeries {
|
||||
date: Scalars['String']
|
||||
creditsUsed: Scalars['Float']
|
||||
__typename: 'BillingUsageTimeSeries'
|
||||
}
|
||||
|
||||
export interface BillingUserDailyUsage {
|
||||
userWorkspaceId: Scalars['String']
|
||||
dailyUsage: BillingUsageTimeSeries[]
|
||||
__typename: 'BillingUserDailyUsage'
|
||||
}
|
||||
|
||||
export interface BillingAnalytics {
|
||||
usageByUser: BillingUsageBreakdownItem[]
|
||||
usageByResource: BillingUsageBreakdownItem[]
|
||||
usageByExecutionType: BillingUsageBreakdownItem[]
|
||||
timeSeries: BillingUsageTimeSeries[]
|
||||
periodStart: Scalars['DateTime']
|
||||
periodEnd: Scalars['DateTime']
|
||||
userDailyUsage?: BillingUserDailyUsage
|
||||
__typename: 'BillingAnalytics'
|
||||
}
|
||||
|
||||
export interface EnterpriseLicenseInfoDTO {
|
||||
isValid: Scalars['Boolean']
|
||||
licensee?: Scalars['String']
|
||||
@@ -1315,7 +1345,7 @@ export interface FeatureFlag {
|
||||
__typename: 'FeatureFlag'
|
||||
}
|
||||
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED'
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED'
|
||||
|
||||
export interface SSOIdentityProvider {
|
||||
id: Scalars['UUID']
|
||||
@@ -2603,6 +2633,7 @@ export interface Query {
|
||||
billingPortalSession: BillingSession
|
||||
listPlans: BillingPlan[]
|
||||
getMeteredProductsUsage: BillingMeteredProductUsage[]
|
||||
getBillingAnalytics: BillingAnalytics
|
||||
enterprisePortalSession?: Scalars['String']
|
||||
enterpriseCheckoutSession?: Scalars['String']
|
||||
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTO
|
||||
@@ -2684,7 +2715,7 @@ export type SortDirection = 'ASC' | 'DESC'
|
||||
/** Sort Nulls Options */
|
||||
export type SortNulls = 'NULLS_FIRST' | 'NULLS_LAST'
|
||||
|
||||
export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT'
|
||||
export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT' | 'BILLING_EVENT'
|
||||
|
||||
export interface Mutation {
|
||||
addQueryToEventStream: Scalars['Boolean']
|
||||
@@ -4120,6 +4151,40 @@ export interface BillingUpdateGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface BillingUsageBreakdownItemGenqlSelection{
|
||||
key?: boolean | number
|
||||
label?: boolean | number
|
||||
creditsUsed?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface BillingUsageTimeSeriesGenqlSelection{
|
||||
date?: boolean | number
|
||||
creditsUsed?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface BillingUserDailyUsageGenqlSelection{
|
||||
userWorkspaceId?: boolean | number
|
||||
dailyUsage?: BillingUsageTimeSeriesGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface BillingAnalyticsGenqlSelection{
|
||||
usageByUser?: BillingUsageBreakdownItemGenqlSelection
|
||||
usageByResource?: BillingUsageBreakdownItemGenqlSelection
|
||||
usageByExecutionType?: BillingUsageBreakdownItemGenqlSelection
|
||||
timeSeries?: BillingUsageTimeSeriesGenqlSelection
|
||||
periodStart?: boolean | number
|
||||
periodEnd?: boolean | number
|
||||
userDailyUsage?: BillingUserDailyUsageGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface EnterpriseLicenseInfoDTOGenqlSelection{
|
||||
isValid?: boolean | number
|
||||
licensee?: boolean | number
|
||||
@@ -5664,6 +5729,7 @@ export interface QueryGenqlSelection{
|
||||
billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null)} })
|
||||
listPlans?: BillingPlanGenqlSelection
|
||||
getMeteredProductsUsage?: BillingMeteredProductUsageGenqlSelection
|
||||
getBillingAnalytics?: (BillingAnalyticsGenqlSelection & { __args?: {input?: (BillingAnalyticsInput | null)} })
|
||||
enterprisePortalSession?: { __args: {returnUrlPath?: (Scalars['String'] | null)} } | boolean | number
|
||||
enterpriseCheckoutSession?: { __args: {billingInterval?: (Scalars['String'] | null)} } | boolean | number
|
||||
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTOGenqlSelection
|
||||
@@ -5756,6 +5822,8 @@ export interface AgentIdInput {
|
||||
/** The id of the agent. */
|
||||
id: Scalars['UUID']}
|
||||
|
||||
export interface BillingAnalyticsInput {periodStart?: (Scalars['DateTime'] | null),periodEnd?: (Scalars['DateTime'] | null),userWorkspaceId?: (Scalars['String'] | null)}
|
||||
|
||||
export interface GetApiKeyInput {id: Scalars['UUID']}
|
||||
|
||||
export interface AgentChatThreadFilter {and?: (AgentChatThreadFilter[] | null),or?: (AgentChatThreadFilter[] | null),id?: (UUIDFilterComparison | null),updatedAt?: (DateFieldComparison | null)}
|
||||
@@ -7029,6 +7097,38 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const BillingUsageBreakdownItem_possibleTypes: string[] = ['BillingUsageBreakdownItem']
|
||||
export const isBillingUsageBreakdownItem = (obj?: { __typename?: any } | null): obj is BillingUsageBreakdownItem => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingUsageBreakdownItem"')
|
||||
return BillingUsageBreakdownItem_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const BillingUsageTimeSeries_possibleTypes: string[] = ['BillingUsageTimeSeries']
|
||||
export const isBillingUsageTimeSeries = (obj?: { __typename?: any } | null): obj is BillingUsageTimeSeries => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingUsageTimeSeries"')
|
||||
return BillingUsageTimeSeries_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const BillingUserDailyUsage_possibleTypes: string[] = ['BillingUserDailyUsage']
|
||||
export const isBillingUserDailyUsage = (obj?: { __typename?: any } | null): obj is BillingUserDailyUsage => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingUserDailyUsage"')
|
||||
return BillingUserDailyUsage_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const BillingAnalytics_possibleTypes: string[] = ['BillingAnalytics']
|
||||
export const isBillingAnalytics = (obj?: { __typename?: any } | null): obj is BillingAnalytics => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingAnalytics"')
|
||||
return BillingAnalytics_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO']
|
||||
export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"')
|
||||
@@ -8698,6 +8798,7 @@ export const enumFeatureFlagKey = {
|
||||
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED: 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' as const,
|
||||
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' as const,
|
||||
IS_DRAFT_EMAIL_ENABLED: 'IS_DRAFT_EMAIL_ENABLED' as const,
|
||||
IS_USAGE_ANALYTICS_ENABLED: 'IS_USAGE_ANALYTICS_ENABLED' as const,
|
||||
IS_RICH_TEXT_V1_MIGRATED: 'IS_RICH_TEXT_V1_MIGRATED' as const
|
||||
}
|
||||
|
||||
@@ -8942,7 +9043,8 @@ export const enumSortNulls = {
|
||||
export const enumEventLogTable = {
|
||||
WORKSPACE_EVENT: 'WORKSPACE_EVENT' as const,
|
||||
PAGEVIEW: 'PAGEVIEW' as const,
|
||||
OBJECT_EVENT: 'OBJECT_EVENT' as const
|
||||
OBJECT_EVENT: 'OBJECT_EVENT' as const,
|
||||
BILLING_EVENT: 'BILLING_EVENT' as const
|
||||
}
|
||||
|
||||
export const enumAnalyticsType = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+15
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS billingEvent
|
||||
(
|
||||
`timestamp` DateTime64(3) NOT NULL,
|
||||
`workspaceId` String NOT NULL,
|
||||
`userWorkspaceId` String DEFAULT '',
|
||||
`eventType` LowCardinality(String) NOT NULL,
|
||||
`executionType` LowCardinality(String) NOT NULL,
|
||||
`creditsUsed` Float64 NOT NULL DEFAULT 0,
|
||||
`resourceId` String DEFAULT '',
|
||||
`resourceContext` String DEFAULT '',
|
||||
`metadata` JSON
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
ORDER BY (workspaceId, timestamp, executionType, userWorkspaceId, resourceId)
|
||||
TTL timestamp + INTERVAL 3 YEAR DELETE;
|
||||
@@ -18,7 +18,9 @@ import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entit
|
||||
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
|
||||
import { BillingFeatureUsedListener } from 'src/engine/core-modules/billing/listeners/billing-feature-used.listener';
|
||||
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener';
|
||||
import { BillingAnalyticsService } from 'src/engine/core-modules/billing/services/billing-analytics.service';
|
||||
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
|
||||
import { BillingEventWriterService } from 'src/engine/core-modules/billing/services/billing-event-writer.service';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
|
||||
@@ -36,11 +38,13 @@ import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
|
||||
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@@ -64,9 +68,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
WorkspaceEntity,
|
||||
UserWorkspaceEntity,
|
||||
FeatureFlagEntity,
|
||||
AgentEntity,
|
||||
]),
|
||||
DataSourceModule,
|
||||
MetricsModule,
|
||||
ClickHouseModule,
|
||||
EnterpriseModule,
|
||||
],
|
||||
providers: [
|
||||
@@ -90,6 +96,8 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
BillingCreditRolloverService,
|
||||
MeteredCreditService,
|
||||
BillingGaugeService,
|
||||
BillingEventWriterService,
|
||||
BillingAnalyticsService,
|
||||
],
|
||||
exports: [
|
||||
BillingSubscriptionService,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { In, type Repository } from 'typeorm';
|
||||
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||
@@ -15,7 +19,13 @@ import { BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/d
|
||||
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
|
||||
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
|
||||
import { BillingAnalyticsDTO } from 'src/engine/core-modules/billing/dtos/billing-analytics.dto';
|
||||
import { BillingAnalyticsInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-analytics.input';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import {
|
||||
type BillingUsageBreakdownItem,
|
||||
BillingAnalyticsService,
|
||||
} from 'src/engine/core-modules/billing/services/billing-analytics.service';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||
import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
|
||||
@@ -30,6 +40,7 @@ import {
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
@@ -46,7 +57,12 @@ 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 {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
|
||||
@MetadataResolver()
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@@ -62,7 +78,12 @@ export class BillingResolver {
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly billingAnalyticsService: BillingAnalyticsService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
) {}
|
||||
|
||||
@Query(() => BillingSessionDTO)
|
||||
@@ -316,6 +337,82 @@ export class BillingResolver {
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => BillingAnalyticsDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED)
|
||||
async getBillingAnalytics(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args('input', { nullable: true }) input?: BillingAnalyticsInput,
|
||||
): Promise<BillingAnalyticsDTO> {
|
||||
const { defaultPeriodStart, defaultPeriodEnd } =
|
||||
await this.getDefaultAnalyticsPeriod(workspace.id);
|
||||
|
||||
const periodStart = input?.periodStart ?? defaultPeriodStart;
|
||||
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
|
||||
|
||||
const periodParams = {
|
||||
workspaceId: workspace.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
|
||||
const [usageByUser, usageByResource, usageByExecutionType, timeSeries] =
|
||||
await Promise.all([
|
||||
this.billingAnalyticsService.getUsageByUser(periodParams),
|
||||
this.billingAnalyticsService.getUsageByResource(periodParams),
|
||||
this.billingAnalyticsService.getUsageByExecutionType({
|
||||
...periodParams,
|
||||
userWorkspaceId: input?.userWorkspaceId ?? undefined,
|
||||
}),
|
||||
this.billingAnalyticsService.getUsageTimeSeries(periodParams),
|
||||
]);
|
||||
|
||||
const resolvedUsageByUser = await this.resolveBreakdownKeys(
|
||||
usageByUser,
|
||||
(ids) => this.resolveUserNames(ids, workspace.id),
|
||||
);
|
||||
|
||||
const resolvedUsageByResource = await this.resolveBreakdownKeys(
|
||||
usageByResource,
|
||||
(ids) => this.resolveResourceNames(ids, workspace.id),
|
||||
);
|
||||
|
||||
const result: BillingAnalyticsDTO = {
|
||||
usageByUser: resolvedUsageByUser,
|
||||
usageByResource: resolvedUsageByResource,
|
||||
usageByExecutionType,
|
||||
timeSeries,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
|
||||
if (input?.userWorkspaceId) {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: input.userWorkspaceId, workspaceId: workspace.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (isDefined(userWorkspace)) {
|
||||
const dailyUsage =
|
||||
await this.billingAnalyticsService.getUsageByUserTimeSeries({
|
||||
...periodParams,
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
});
|
||||
|
||||
result.userDailyUsage = {
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
dailyUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
@@ -338,6 +435,29 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
private async getDefaultAnalyticsPeriod(
|
||||
workspaceId: string,
|
||||
): Promise<{ defaultPeriodStart: Date; defaultPeriodEnd: Date }> {
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
return {
|
||||
defaultPeriodStart: subscription.currentPeriodStart,
|
||||
defaultPeriodEnd: subscription.currentPeriodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultPeriodEnd = new Date();
|
||||
const defaultPeriodStart = new Date();
|
||||
|
||||
defaultPeriodStart.setDate(defaultPeriodStart.getDate() - 30);
|
||||
|
||||
return { defaultPeriodStart, defaultPeriodEnd };
|
||||
}
|
||||
|
||||
private async validateCanCheckoutSessionPermissionOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
@@ -374,7 +494,77 @@ export class BillingResolver {
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
private async resolveBreakdownKeys(
|
||||
items: BillingUsageBreakdownItem[],
|
||||
resolveNames: (ids: string[]) => Promise<Map<string, string>>,
|
||||
): Promise<BillingUsageBreakdownItem[]> {
|
||||
if (items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const ids = items.map((item) => item.key);
|
||||
const nameMap = await resolveNames(ids);
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
label: nameMap.get(item.key),
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveUserNames(
|
||||
userWorkspaceIds: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const nameMap = new Map<string, string>();
|
||||
|
||||
if (userWorkspaceIds.length === 0) {
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: { id: In(userWorkspaceIds), workspaceId },
|
||||
relations: ['user'],
|
||||
select: {
|
||||
id: true,
|
||||
user: { firstName: true, lastName: true, email: true },
|
||||
},
|
||||
});
|
||||
|
||||
for (const userWorkspace of userWorkspaces) {
|
||||
if (!isDefined(userWorkspace.user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { firstName, lastName, email } = userWorkspace.user;
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
nameMap.set(userWorkspace.id, fullName || email);
|
||||
}
|
||||
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
private async resolveResourceNames(
|
||||
resourceIds: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const nameMap = new Map<string, string>();
|
||||
|
||||
if (resourceIds.length === 0) {
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
const agents = await this.agentRepository.find({
|
||||
where: { id: In(resourceIds), workspaceId },
|
||||
select: { id: true, label: true },
|
||||
});
|
||||
|
||||
for (const agent of agents) {
|
||||
nameMap.set(agent.id, agent.label);
|
||||
}
|
||||
|
||||
return nameMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingUsageBreakdownItemDTO } from 'src/engine/core-modules/billing/dtos/billing-usage-breakdown-item.dto';
|
||||
import { BillingUsageTimeSeriesDTO } from 'src/engine/core-modules/billing/dtos/billing-usage-time-series.dto';
|
||||
import { BillingUserDailyUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-user-daily-usage.dto';
|
||||
|
||||
@ObjectType('BillingAnalytics')
|
||||
export class BillingAnalyticsDTO {
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByUser: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByResource: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByExecutionType: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageTimeSeriesDTO])
|
||||
timeSeries: BillingUsageTimeSeriesDTO[];
|
||||
|
||||
@Field(() => Date)
|
||||
periodStart: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
periodEnd: Date;
|
||||
|
||||
@Field(() => BillingUserDailyUsageDTO, { nullable: true })
|
||||
userDailyUsage?: BillingUserDailyUsageDTO;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('BillingUsageBreakdownItem')
|
||||
export class BillingUsageBreakdownItemDTO {
|
||||
@Field(() => String)
|
||||
key: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
label?: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('BillingUsageTimeSeries')
|
||||
export class BillingUsageTimeSeriesDTO {
|
||||
@Field(() => String)
|
||||
date: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingUsageTimeSeriesDTO } from 'src/engine/core-modules/billing/dtos/billing-usage-time-series.dto';
|
||||
|
||||
@ObjectType('BillingUserDailyUsage')
|
||||
export class BillingUserDailyUsageDTO {
|
||||
@Field(() => String)
|
||||
userWorkspaceId: string;
|
||||
|
||||
@Field(() => [BillingUsageTimeSeriesDTO])
|
||||
dailyUsage: BillingUsageTimeSeriesDTO[];
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
@InputType()
|
||||
export class BillingAnalyticsInput {
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodStart?: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodEnd?: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
userWorkspaceId?: string;
|
||||
}
|
||||
+3
-1
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingEventWriterService } from 'src/engine/core-modules/billing/services/billing-event-writer.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@@ -15,6 +16,7 @@ import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/ty
|
||||
export class BillingFeatureUsedListener {
|
||||
constructor(
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly billingEventWriterService: BillingEventWriterService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
@@ -38,7 +40,7 @@ export class BillingFeatureUsedListener {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.billingUsageService.billUsage({
|
||||
await this.billingEventWriterService.writeAndBill({
|
||||
workspaceId: payload.workspaceId,
|
||||
billingEvents: payload.events,
|
||||
});
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
|
||||
|
||||
export type BillingUsageBreakdownItem = {
|
||||
key: string;
|
||||
label?: string;
|
||||
creditsUsed: number;
|
||||
};
|
||||
|
||||
export type BillingUsageTimeSeriesPoint = {
|
||||
date: string;
|
||||
creditsUsed: number;
|
||||
};
|
||||
|
||||
type PeriodParams = {
|
||||
workspaceId: string;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
};
|
||||
|
||||
const ALLOWED_GROUP_BY_FIELDS = [
|
||||
'userWorkspaceId',
|
||||
'resourceId',
|
||||
'executionType',
|
||||
] as const;
|
||||
|
||||
type GroupByField = (typeof ALLOWED_GROUP_BY_FIELDS)[number];
|
||||
|
||||
const BREAKDOWN_QUERY_LIMIT = 50;
|
||||
|
||||
@Injectable()
|
||||
export class BillingAnalyticsService {
|
||||
constructor(private readonly clickHouseService: ClickHouseService) {}
|
||||
|
||||
async getUsageByUser(
|
||||
params: PeriodParams,
|
||||
): Promise<BillingUsageBreakdownItem[]> {
|
||||
return this.queryBreakdown({
|
||||
...params,
|
||||
groupByField: 'userWorkspaceId',
|
||||
extraWhere: "AND userWorkspaceId != ''",
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageByResource(
|
||||
params: PeriodParams,
|
||||
): Promise<BillingUsageBreakdownItem[]> {
|
||||
return this.queryBreakdown({
|
||||
...params,
|
||||
groupByField: 'resourceId',
|
||||
extraWhere: "AND resourceId != ''",
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageByExecutionType(
|
||||
params: PeriodParams & { userWorkspaceId?: string },
|
||||
): Promise<BillingUsageBreakdownItem[]> {
|
||||
return this.queryBreakdown({
|
||||
...params,
|
||||
groupByField: 'executionType',
|
||||
...(params.userWorkspaceId && {
|
||||
extraWhere: 'AND userWorkspaceId = {userWorkspaceId:String}',
|
||||
extraParams: { userWorkspaceId: params.userWorkspaceId },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageByUserTimeSeries(
|
||||
params: PeriodParams & { userWorkspaceId: string },
|
||||
): Promise<BillingUsageTimeSeriesPoint[]> {
|
||||
return this.queryTimeSeries({
|
||||
...params,
|
||||
extraWhere: 'AND userWorkspaceId = {userWorkspaceId:String}',
|
||||
extraParams: { userWorkspaceId: params.userWorkspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageTimeSeries(
|
||||
params: PeriodParams,
|
||||
): Promise<BillingUsageTimeSeriesPoint[]> {
|
||||
return this.queryTimeSeries(params);
|
||||
}
|
||||
|
||||
private async queryBreakdown({
|
||||
workspaceId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
groupByField,
|
||||
extraWhere = '',
|
||||
extraParams,
|
||||
}: PeriodParams & {
|
||||
groupByField: GroupByField;
|
||||
extraWhere?: string;
|
||||
extraParams?: Record<string, unknown>;
|
||||
}): Promise<BillingUsageBreakdownItem[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
${groupByField} AS key,
|
||||
sum(creditsUsed) AS creditsUsed
|
||||
FROM billingEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:String}
|
||||
AND timestamp < {periodEnd:String}
|
||||
${extraWhere}
|
||||
GROUP BY ${groupByField}
|
||||
ORDER BY creditsUsed DESC
|
||||
LIMIT ${BREAKDOWN_QUERY_LIMIT}
|
||||
`;
|
||||
|
||||
const rows = await this.clickHouseService.select<BillingUsageBreakdownItem>(
|
||||
query,
|
||||
{
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...(extraParams ?? {}),
|
||||
},
|
||||
);
|
||||
|
||||
return this.mapToDisplayCredits(rows);
|
||||
}
|
||||
|
||||
private async queryTimeSeries({
|
||||
workspaceId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
extraWhere = '',
|
||||
extraParams,
|
||||
}: PeriodParams & {
|
||||
extraWhere?: string;
|
||||
extraParams?: Record<string, unknown>;
|
||||
}): Promise<BillingUsageTimeSeriesPoint[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
formatDateTime(timestamp, '%Y-%m-%d') AS date,
|
||||
sum(creditsUsed) AS creditsUsed
|
||||
FROM billingEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:String}
|
||||
AND timestamp < {periodEnd:String}
|
||||
${extraWhere}
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
|
||||
const rows =
|
||||
await this.clickHouseService.select<BillingUsageTimeSeriesPoint>(query, {
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...(extraParams ?? {}),
|
||||
});
|
||||
|
||||
return this.mapToDisplayCredits(rows);
|
||||
}
|
||||
|
||||
private mapToDisplayCredits<T extends { creditsUsed: number }>(
|
||||
rows: T[],
|
||||
): T[] {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
creditsUsed: toDisplayCredits(row.creditsUsed),
|
||||
}));
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingEventWriterService {
|
||||
private readonly logger = new Logger(BillingEventWriterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async writeAndBill({
|
||||
workspaceId,
|
||||
billingEvents,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
billingEvents: BillingUsageEvent[];
|
||||
}): Promise<void> {
|
||||
// Best-effort ClickHouse write — never blocks Stripe billing
|
||||
this.writeToClickHouse(workspaceId, billingEvents);
|
||||
|
||||
await this.billingUsageService.billUsage({
|
||||
workspaceId,
|
||||
billingEvents,
|
||||
});
|
||||
}
|
||||
|
||||
private writeToClickHouse(
|
||||
workspaceId: string,
|
||||
billingEvents: BillingUsageEvent[],
|
||||
): void {
|
||||
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = formatDateForClickHouse(new Date());
|
||||
|
||||
const rows = billingEvents.map((event) => ({
|
||||
timestamp: now,
|
||||
workspaceId,
|
||||
userWorkspaceId: event.userWorkspaceId ?? '',
|
||||
eventType: event.eventName,
|
||||
executionType: event.dimensions?.execution_type ?? '',
|
||||
creditsUsed: event.value,
|
||||
resourceId: event.dimensions?.resource_id ?? '',
|
||||
resourceContext: event.dimensions?.execution_context_1 ?? '',
|
||||
metadata: '{}',
|
||||
}));
|
||||
|
||||
this.clickHouseService.insert('billingEvent', rows).catch((error) => {
|
||||
this.logger.error('Failed to write billing events to ClickHouse', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
@@ -9,4 +9,5 @@ export type BillingUsageEvent = {
|
||||
eventName: BillingMeterEventName;
|
||||
value: NonNegative<number>;
|
||||
dimensions?: BillingDimensions;
|
||||
userWorkspaceId?: string | null;
|
||||
};
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
|
||||
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
|
||||
[EventLogTable.PAGEVIEW]: 'pageview',
|
||||
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
|
||||
[EventLogTable.BILLING_EVENT]: 'billingEvent',
|
||||
};
|
||||
|
||||
export type EventLogCleanupParams = {
|
||||
|
||||
@@ -36,6 +36,17 @@ type ClickHouseEventRecord = {
|
||||
isCustom?: boolean;
|
||||
};
|
||||
|
||||
type ClickHouseBillingEventRecord = {
|
||||
timestamp: string;
|
||||
userWorkspaceId?: string;
|
||||
eventType?: string;
|
||||
executionType?: string;
|
||||
creditsUsed?: number;
|
||||
resourceId?: string;
|
||||
resourceContext?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ALLOWED_TABLES = Object.values(EventLogTable);
|
||||
const MAX_LIMIT = 10000;
|
||||
|
||||
@@ -43,6 +54,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
|
||||
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
|
||||
[EventLogTable.PAGEVIEW]: 'pageview',
|
||||
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
|
||||
[EventLogTable.BILLING_EVENT]: 'billingEvent',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -67,7 +79,11 @@ export class EventLogsService {
|
||||
const limit = Math.min(input.first ?? 100, MAX_LIMIT);
|
||||
const tableName = CLICKHOUSE_TABLE_NAMES[input.table];
|
||||
const eventFieldName =
|
||||
input.table === EventLogTable.PAGEVIEW ? 'name' : 'event';
|
||||
input.table === EventLogTable.BILLING_EVENT
|
||||
? 'eventType'
|
||||
: input.table === EventLogTable.PAGEVIEW
|
||||
? 'name'
|
||||
: 'event';
|
||||
|
||||
const whereClauses: string[] = ['"workspaceId" = {workspaceId:String}'];
|
||||
const params: Record<string, unknown> = { workspaceId };
|
||||
@@ -179,14 +195,19 @@ export class EventLogsService {
|
||||
}
|
||||
|
||||
if (isDefined(filters.userWorkspaceId)) {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: filters.userWorkspaceId },
|
||||
select: ['userId'],
|
||||
});
|
||||
if (table === EventLogTable.BILLING_EVENT) {
|
||||
whereClauses.push('"userWorkspaceId" = {userWorkspaceId:String}');
|
||||
params.userWorkspaceId = filters.userWorkspaceId;
|
||||
} else {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: filters.userWorkspaceId },
|
||||
select: ['userId'],
|
||||
});
|
||||
|
||||
if (isDefined(userWorkspace)) {
|
||||
whereClauses.push('"userId" = {userId:String}');
|
||||
params.userId = userWorkspace.userId;
|
||||
if (isDefined(userWorkspace)) {
|
||||
whereClauses.push('"userId" = {userId:String}');
|
||||
params.userId = userWorkspace.userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,10 +243,25 @@ export class EventLogsService {
|
||||
}
|
||||
|
||||
private normalizeRecords(
|
||||
records: ClickHouseEventRecord[],
|
||||
records: ClickHouseEventRecord[] | ClickHouseBillingEventRecord[],
|
||||
table: EventLogTable,
|
||||
): EventLogRecord[] {
|
||||
return records.map((record) => {
|
||||
if (table === EventLogTable.BILLING_EVENT) {
|
||||
return (records as ClickHouseBillingEventRecord[]).map((record) => ({
|
||||
event: record.eventType ?? '',
|
||||
timestamp: new Date(record.timestamp),
|
||||
userId: record.userWorkspaceId,
|
||||
properties: {
|
||||
executionType: record.executionType,
|
||||
creditsUsed: record.creditsUsed,
|
||||
resourceId: record.resourceId,
|
||||
resourceContext: record.resourceContext,
|
||||
...(record.metadata ?? {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return (records as ClickHouseEventRecord[]).map((record) => {
|
||||
const eventName =
|
||||
table === EventLogTable.PAGEVIEW
|
||||
? (record.name ?? '')
|
||||
|
||||
+1
@@ -347,6 +347,7 @@ describe('AIBillingService', () => {
|
||||
resource_id: 'agent-id-123',
|
||||
execution_context_1: 'gpt-4o',
|
||||
},
|
||||
userWorkspaceId: null,
|
||||
},
|
||||
],
|
||||
'workspace-1',
|
||||
|
||||
+10
-1
@@ -59,13 +59,20 @@ export class AIBillingService {
|
||||
billingInput: BillingUsageInput,
|
||||
workspaceId: string,
|
||||
agentId?: string | null,
|
||||
userWorkspaceId?: string | null,
|
||||
): void {
|
||||
const costInDollars = this.calculateCost(modelId, billingInput);
|
||||
const creditsUsed = Math.round(
|
||||
convertDollarsToBillingCredits(costInDollars),
|
||||
);
|
||||
|
||||
this.sendAiTokenUsageEvent(workspaceId, creditsUsed, modelId, agentId);
|
||||
this.sendAiTokenUsageEvent(
|
||||
workspaceId,
|
||||
creditsUsed,
|
||||
modelId,
|
||||
agentId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private sendAiTokenUsageEvent(
|
||||
@@ -73,6 +80,7 @@ export class AIBillingService {
|
||||
creditsUsed: number,
|
||||
modelId: ModelId,
|
||||
agentId?: string | null,
|
||||
userWorkspaceId?: string | null,
|
||||
): void {
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
|
||||
BILLING_FEATURE_USED,
|
||||
@@ -85,6 +93,7 @@ export class AIBillingService {
|
||||
resource_id: agentId || null,
|
||||
execution_context_1: modelId,
|
||||
},
|
||||
userWorkspaceId: userWorkspaceId || null,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
|
||||
+1
@@ -243,6 +243,7 @@ export class ChatExecutionService {
|
||||
{ usage, cacheCreationTokens },
|
||||
workspace.id,
|
||||
null,
|
||||
userWorkspaceId,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
+1
@@ -246,6 +246,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_MARKETPLACE_ENABLED: false,
|
||||
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED: false,
|
||||
IS_DRAFT_EMAIL_ENABLED: false,
|
||||
IS_USAGE_ANALYTICS_ENABLED: false,
|
||||
IS_RICH_TEXT_V1_MIGRATED: false,
|
||||
},
|
||||
userWorkspaceRoleMap: {},
|
||||
|
||||
+5
@@ -105,6 +105,11 @@ export const seedFeatureFlags = async ({
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
+6
@@ -73,6 +73,11 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
const executionContext =
|
||||
await this.workflowExecutionContextService.getExecutionContext(runInfo);
|
||||
|
||||
const userWorkspaceId =
|
||||
executionContext.authContext.type === 'user'
|
||||
? executionContext.authContext.userWorkspaceId
|
||||
: null;
|
||||
|
||||
const { result, usage, cacheCreationTokens } =
|
||||
await this.aiAgentExecutionService.executeAgent({
|
||||
agent,
|
||||
@@ -89,6 +94,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
{ usage, cacheCreationTokens },
|
||||
workspaceId,
|
||||
agent?.id || null,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,4 +2,5 @@ export enum EventLogTable {
|
||||
WORKSPACE_EVENT = 'WORKSPACE_EVENT',
|
||||
PAGEVIEW = 'PAGEVIEW',
|
||||
OBJECT_EVENT = 'OBJECT_EVENT',
|
||||
BILLING_EVENT = 'BILLING_EVENT',
|
||||
}
|
||||
|
||||
@@ -18,5 +18,6 @@ export enum FeatureFlagKey {
|
||||
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED',
|
||||
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED',
|
||||
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
|
||||
IS_USAGE_ANALYTICS_ENABLED = 'IS_USAGE_ANALYTICS_ENABLED',
|
||||
IS_RICH_TEXT_V1_MIGRATED = 'IS_RICH_TEXT_V1_MIGRATED',
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export enum SettingsPath {
|
||||
NewImapSmtpCaldavConnection = 'accounts/new-imap-smtp-caldav-connection',
|
||||
EditImapSmtpCaldavConnection = 'accounts/edit-imap-smtp-caldav-connection/:connectedAccountId',
|
||||
Billing = 'billing',
|
||||
Usage = 'usage',
|
||||
UsageUserDetail = 'usage/user/:userWorkspaceId',
|
||||
Enterprise = 'enterprise',
|
||||
Objects = 'objects',
|
||||
ObjectOverview = 'objects/overview',
|
||||
|
||||
Reference in New Issue
Block a user