diff --git a/packages/twenty-front/src/modules/billing/components/BillingChartContainers.tsx b/packages/twenty-front/src/modules/billing/components/BillingChartContainers.tsx new file mode 100644 index 00000000000..4419a8d41dc --- /dev/null +++ b/packages/twenty-front/src/modules/billing/components/BillingChartContainers.tsx @@ -0,0 +1,11 @@ +import { styled } from '@linaria/react'; + +export const BillingLineChartContainer = styled.div` + height: 200px; + width: 100%; +`; + +export const BillingPieChartContainer = styled.div` + height: 220px; + width: 100%; +`; diff --git a/packages/twenty-front/src/modules/billing/components/BillingChartTooltip.tsx b/packages/twenty-front/src/modules/billing/components/BillingChartTooltip.tsx new file mode 100644 index 00000000000..75d4599fa2f --- /dev/null +++ b/packages/twenty-front/src/modules/billing/components/BillingChartTooltip.tsx @@ -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; +}) => ( + + {label}: {value} + +); diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingAnalyticsSection.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingAnalyticsSection.tsx index e2364da67f9..888722a3a58 100644 --- a/packages/twenty-front/src/modules/billing/components/SettingsBillingAnalyticsSection.tsx +++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingAnalyticsSection.tsx @@ -1,5 +1,17 @@ +import { BillingChartTooltip } from '@/billing/components/BillingChartTooltip'; +import { + BillingLineChartContainer, + BillingPieChartContainer, +} 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/billing-analytics.utils'; 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'; @@ -25,21 +37,6 @@ import { GetBillingAnalyticsDocument } from '~/generated-metadata/graphql'; import { formatDate } from '~/utils/date-utils'; import { normalizeSearchText } from '~/utils/normalizeSearchText'; -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; - } -}; - -type PeriodPreset = '7d' | '30d' | '90d'; - const StyledBarRow = styled.div` display: flex; flex-direction: column; @@ -68,16 +65,6 @@ const StyledValueText = styled.span` font-weight: ${themeCssVariables.font.weight.medium}; `; -const StyledChartContainer = styled.div` - height: 200px; - width: 100%; -`; - -const StyledPieChartContainer = styled.div` - height: 220px; - width: 100%; -`; - const StyledSearchInputContainer = styled.div` padding-bottom: ${themeCssVariables.spacing[2]}; `; @@ -86,61 +73,8 @@ const StyledIconChevronRightContainer = styled.div` color: ${themeCssVariables.font.color.tertiary}; `; -const StyledClickableTable = styled(Table)` - a > * { - cursor: pointer; - } -`; - const USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS = '1fr 120px 36px'; -const getChartColors = (theme: { - color: { - blue: string; - purple: string; - turquoise: string; - orange: string; - pink: string; - green: string; - }; -}): string[] => [ - theme.color.blue, - theme.color.purple, - theme.color.turquoise, - theme.color.orange, - theme.color.pink, - theme.color.green, -]; - -const getPeriodDates = ( - preset: PeriodPreset, -): { periodStart: string; periodEnd: string } => { - const now = new Date(); - const daysMap: Record = { - '7d': 7, - '30d': 30, - '90d': 90, - }; - const start = new Date(now); - - start.setDate(start.getDate() - daysMap[preset]); - - return { - periodStart: start.toISOString(), - periodEnd: now.toISOString(), - }; -}; - -const usePeriodOptions = () => - useMemo( - () => [ - { value: '7d' as const, label: t`Last 7 days` }, - { value: '30d' as const, label: t`Last 30 days` }, - { value: '90d' as const, label: t`Last 90 days` }, - ], - [], - ); - export const SettingsBillingAnalyticsSection = () => { const { theme } = useContext(ThemeContext); const { formatNumber } = useNumberFormat(); @@ -151,9 +85,9 @@ export const SettingsBillingAnalyticsSection = () => { const [resourcePeriod, setResourcePeriod] = useState('30d'); const [userSearchTerm, setUserSearchTerm] = useState(''); - const chartColors = useMemo(() => getChartColors(theme), [theme]); + const chartColors = getChartColors(theme); const lineChartTheme = useLineChartTheme(); - const periodOptions = usePeriodOptions(); + const periodOptions = getPeriodOptions(); const typeDates = useMemo(() => getPeriodDates(typePeriod), [typePeriod]); const dailyDates = useMemo(() => getPeriodDates(dailyPeriod), [dailyPeriod]); @@ -193,17 +127,6 @@ export const SettingsBillingAnalyticsSection = () => { const usageByUser = userAnalytics?.usageByUser ?? []; const usageByResource = resourceAnalytics?.usageByResource ?? []; - const filteredUsageByUser = useMemo( - () => - usageByUser.filter((item) => { - const search = normalizeSearchText(userSearchTerm); - const name = normalizeSearchText(item.label ?? item.key); - - return name.includes(search); - }), - [usageByUser, userSearchTerm], - ); - const allLoading = typeLoading && dailyLoading && userLoading && resourceLoading; @@ -222,6 +145,18 @@ export const SettingsBillingAnalyticsSection = () => { 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, @@ -274,7 +209,7 @@ export const SettingsBillingAnalyticsSection = () => { } /> - + { animate motionConfig={CHART_MOTION_CONFIG} tooltip={({ datum }) => ( -
- {datum.id}: {formatNumber(datum.value)}{' '} - {t`credits`} -
+ )} /> -
+
)} @@ -330,7 +255,7 @@ export const SettingsBillingAnalyticsSection = () => { } /> - + { ? lineData[0].data .filter( (_, index) => - index % - Math.ceil(timeSeries.length / 7) === - 0, + index % Math.ceil(timeSeries.length / 7) === 0, ) .map((point) => point.x) : undefined, @@ -378,24 +301,13 @@ export const SettingsBillingAnalyticsSection = () => { theme={lineChartTheme} enableSlices="x" sliceTooltip={({ slice }) => ( -
- {slice.points[0]?.data.xFormatted}:{' '} - {formatNumber(Number(slice.points[0]?.data.yFormatted))}{' '} - {t`credits`} -
+ )} /> -
+
)} @@ -423,7 +335,7 @@ export const SettingsBillingAnalyticsSection = () => { onChange={setUserSearchTerm} /> - + @@ -464,7 +376,7 @@ export const SettingsBillingAnalyticsSection = () => { ))} - +
)} @@ -486,10 +398,6 @@ export const SettingsBillingAnalyticsSection = () => { /> {usageByResource.map((item, index) => { - const resourceTotal = usageByResource.reduce( - (sum, resource) => sum + resource.creditsUsed, - 0, - ); const percentage = resourceTotal > 0 ? (item.creditsUsed / resourceTotal) * 100 @@ -498,9 +406,7 @@ export const SettingsBillingAnalyticsSection = () => { return ( - - {item.label ?? item.key} - + {item.label ?? item.key} {formatNumber(item.creditsUsed)} {t`credits`} diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingContent.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingContent.tsx index 0178851ea05..5b9c0d1387f 100644 --- a/packages/twenty-front/src/modules/billing/components/SettingsBillingContent.tsx +++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingContent.tsx @@ -22,9 +22,11 @@ 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(); @@ -53,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); @@ -84,20 +90,24 @@ export const SettingsBillingContent = () => { } /> )} - -
- - -
+ {isUsageAnalyticsEnabled && ( + <> + +
+ + +
+ + )}
= { + '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]); diff --git a/packages/twenty-front/src/modules/ui/layout/table/components/TableCell.tsx b/packages/twenty-front/src/modules/ui/layout/table/components/TableCell.tsx index b48dee0bb47..919f2f30cfb 100644 --- a/packages/twenty-front/src/modules/ui/layout/table/components/TableCell.tsx +++ b/packages/twenty-front/src/modules/ui/layout/table/components/TableCell.tsx @@ -18,7 +18,7 @@ type TableCellProps = { const StyledTableCell = styled.div` 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]}; diff --git a/packages/twenty-front/src/pages/settings/SettingsUsageUserDetail.tsx b/packages/twenty-front/src/pages/settings/SettingsUsageUserDetail.tsx index 83bb126f10a..e1c116fb3b3 100644 --- a/packages/twenty-front/src/pages/settings/SettingsUsageUserDetail.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsUsageUserDetail.tsx @@ -1,5 +1,17 @@ +import { BillingChartTooltip } from '@/billing/components/BillingChartTooltip'; +import { + BillingLineChartContainer, + BillingPieChartContainer, +} 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/billing-analytics.utils'; 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'; @@ -23,18 +35,6 @@ import { useQuery } from '@apollo/client/react'; import { GetBillingAnalyticsDocument } from '~/generated-metadata/graphql'; import { formatDate } from '~/utils/date-utils'; -type PeriodPreset = '7d' | '30d' | '90d'; - -const StyledChartContainer = styled.div` - height: 200px; - width: 100%; -`; - -const StyledPieChartContainer = styled.div` - height: 220px; - width: 100%; -`; - const StyledUserHeader = styled.div` align-items: center; display: flex; @@ -58,75 +58,18 @@ const StyledUserCredits = styled.span` font-size: ${themeCssVariables.font.size.sm}; `; -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; - } -}; - -const getChartColors = (theme: { - color: { - blue: string; - purple: string; - turquoise: string; - orange: string; - pink: string; - green: string; - }; -}): string[] => [ - theme.color.blue, - theme.color.purple, - theme.color.turquoise, - theme.color.orange, - theme.color.pink, - theme.color.green, -]; - -const getPeriodDates = ( - preset: PeriodPreset, -): { periodStart: string; periodEnd: string } => { - const now = new Date(); - const daysMap: Record = { - '7d': 7, - '30d': 30, - '90d': 90, - }; - const start = new Date(now); - - start.setDate(start.getDate() - daysMap[preset]); - - return { - periodStart: start.toISOString(), - periodEnd: now.toISOString(), - }; -}; - export const SettingsUsageUserDetail = () => { const { t: tLingui } = useLingui(); const { userWorkspaceId } = useParams<{ userWorkspaceId: string }>(); const { theme } = useContext(ThemeContext); const { formatNumber } = useNumberFormat(); const lineChartTheme = useLineChartTheme(); - const chartColors = useMemo(() => getChartColors(theme), [theme]); + const chartColors = getChartColors(theme); const [dailyPeriod, setDailyPeriod] = useState('30d'); const [typePeriod, setTypePeriod] = useState('30d'); - const periodOptions = useMemo( - () => [ - { value: '7d' as const, label: t`Last 7 days` }, - { value: '30d' as const, label: t`Last 30 days` }, - { value: '90d' as const, label: t`Last 90 days` }, - ], - [], - ); + const periodOptions = getPeriodOptions(); const dailyDates = useMemo(() => getPeriodDates(dailyPeriod), [dailyPeriod]); const typeDates = useMemo(() => getPeriodDates(typePeriod), [typePeriod]); @@ -164,12 +107,10 @@ export const SettingsUsageUserDetail = () => { const usageByExecutionType = typeAnalytics?.usageByExecutionType ?? []; const userName = - dailyAnalytics?.usageByUser?.find( - (item) => item.key === userWorkspaceId, - )?.label ?? - typeAnalytics?.usageByUser?.find( - (item) => item.key === userWorkspaceId, - )?.label; + 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, @@ -194,7 +135,8 @@ export const SettingsUsageUserDetail = () => { }, ]; - const isInitialLoading = dailyLoading && typeLoading && !dailyData && !typeData; + const isInitialLoading = + dailyLoading && typeLoading && !dailyData && !typeData; const breadcrumbLinks = [ { @@ -252,10 +194,7 @@ export const SettingsUsageUserDetail = () => { } return ( - + { } /> - + { ? lineData[0].data .filter( (_, index) => - index % - Math.ceil(userDailyUsage.length / 7) === + index % Math.ceil(userDailyUsage.length / 7) === 0, ) .map((point) => point.x) @@ -348,26 +286,13 @@ export const SettingsUsageUserDetail = () => { theme={lineChartTheme} enableSlices="x" sliceTooltip={({ slice }) => ( -
- {slice.points[0]?.data.xFormatted}:{' '} - {formatNumber( - Number(slice.points[0]?.data.yFormatted), - )}{' '} - {t`credits`} -
+ )} /> -
+
)} @@ -389,7 +314,7 @@ export const SettingsUsageUserDetail = () => { } /> - + { animate motionConfig={CHART_MOTION_CONFIG} tooltip={({ datum }) => ( -
- {datum.id}: {formatNumber(datum.value)}{' '} - {t`credits`} -
+ )} /> -
+
)} diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index d34409f69bb..64fedb41c2b 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -19,10 +19,8 @@ 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, - BillingAnalyticsInput, -} from 'src/engine/core-modules/billing/dtos/billing-usage-breakdown.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, @@ -350,22 +348,8 @@ export class BillingResolver { @AuthWorkspace() workspace: WorkspaceEntity, @Args('input', { nullable: true }) input?: BillingAnalyticsInput, ): Promise { - let defaultPeriodStart: Date; - let defaultPeriodEnd: Date; - - if (this.billingService.isBillingEnabled()) { - const subscription = - await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow( - { workspaceId: workspace.id }, - ); - - defaultPeriodStart = subscription.currentPeriodStart; - defaultPeriodEnd = subscription.currentPeriodEnd; - } else { - defaultPeriodEnd = new Date(); - defaultPeriodStart = new Date(); - defaultPeriodStart.setDate(defaultPeriodStart.getDate() - 30); - } + const { defaultPeriodStart, defaultPeriodEnd } = + await this.getDefaultAnalyticsPeriod(workspace.id); const periodStart = input?.periodStart ?? defaultPeriodStart; const periodEnd = input?.periodEnd ?? defaultPeriodEnd; @@ -387,17 +371,15 @@ export class BillingResolver { this.billingAnalyticsService.getUsageTimeSeries(periodParams), ]); - const resolvedUsageByUser = - await this.resolveBreakdownKeys( - usageByUser, - (ids) => this.resolveUserNames(ids), - ); + 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 resolvedUsageByResource = await this.resolveBreakdownKeys( + usageByResource, + (ids) => this.resolveResourceNames(ids, workspace.id), + ); const result: BillingAnalyticsDTO = { usageByUser: resolvedUsageByUser, @@ -409,16 +391,23 @@ export class BillingResolver { }; if (input?.userWorkspaceId) { - const dailyUsage = - await this.billingAnalyticsService.getUsageByUserTimeSeries({ - ...periodParams, - userWorkspaceId: input.userWorkspaceId, - }); + const userWorkspace = await this.userWorkspaceRepository.findOne({ + where: { id: input.userWorkspaceId, workspaceId: workspace.id }, + select: { id: true }, + }); - result.userDailyUsage = { - userWorkspaceId: input.userWorkspaceId, - dailyUsage, - }; + if (isDefined(userWorkspace)) { + const dailyUsage = + await this.billingAnalyticsService.getUsageByUserTimeSeries({ + ...periodParams, + userWorkspaceId: input.userWorkspaceId, + }); + + result.userDailyUsage = { + userWorkspaceId: input.userWorkspaceId, + dailyUsage, + }; + } } return result; @@ -446,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, @@ -482,8 +494,6 @@ export class BillingResolver { PermissionsExceptionCode.PERMISSION_DENIED, ); } - - return; } private async resolveBreakdownKeys( @@ -505,6 +515,7 @@ export class BillingResolver { private async resolveUserNames( userWorkspaceIds: string[], + workspaceId: string, ): Promise> { const nameMap = new Map(); @@ -513,9 +524,12 @@ export class BillingResolver { } const userWorkspaces = await this.userWorkspaceRepository.find({ - where: { id: In(userWorkspaceIds) }, + where: { id: In(userWorkspaceIds), workspaceId }, relations: ['user'], - select: { id: true, user: { firstName: true, lastName: true, email: true } }, + select: { + id: true, + user: { firstName: true, lastName: true, email: true }, + }, }); for (const userWorkspace of userWorkspaces) { diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-analytics.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-analytics.dto.ts new file mode 100644 index 00000000000..7507ff5a690 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-analytics.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-breakdown-item.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-breakdown-item.dto.ts new file mode 100644 index 00000000000..43d3072e917 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-breakdown-item.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-breakdown.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-breakdown.dto.ts deleted file mode 100644 index 8f08499be49..00000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-breakdown.dto.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* @license Enterprise */ - -import { Field, Float, InputType, ObjectType } from '@nestjs/graphql'; - -@ObjectType('BillingUsageBreakdownItem') -export class BillingUsageBreakdownItemDTO { - @Field(() => String) - key: string; - - @Field(() => String, { nullable: true }) - label?: string; - - @Field(() => Float) - creditsUsed: number; -} - -@ObjectType('BillingUsageTimeSeries') -export class BillingUsageTimeSeriesDTO { - @Field(() => String) - date: string; - - @Field(() => Float) - creditsUsed: number; -} - -@InputType() -export class BillingAnalyticsInput { - @Field(() => Date, { nullable: true }) - periodStart?: Date; - - @Field(() => Date, { nullable: true }) - periodEnd?: Date; - - @Field(() => String, { nullable: true }) - userWorkspaceId?: string; -} - -@ObjectType('BillingUserDailyUsage') -export class BillingUserDailyUsageDTO { - @Field(() => String) - userWorkspaceId: string; - - @Field(() => [BillingUsageTimeSeriesDTO]) - dailyUsage: BillingUsageTimeSeriesDTO[]; -} - -@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; -} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-time-series.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-time-series.dto.ts new file mode 100644 index 00000000000..8d6ce2d8053 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-usage-time-series.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-user-daily-usage.dto.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-user-daily-usage.dto.ts new file mode 100644 index 00000000000..fc3a68b55fc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/billing-user-daily-usage.dto.ts @@ -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[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-analytics.input.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-analytics.input.ts new file mode 100644 index 00000000000..b6d8ca4a877 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/inputs/billing-analytics.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-analytics.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-analytics.service.ts index 53e39fba68a..656e7a13f26 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-analytics.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-analytics.service.ts @@ -1,6 +1,6 @@ /* @license Enterprise */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service'; import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util'; @@ -23,10 +23,18 @@ type PeriodParams = { 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 { - private readonly logger = new Logger(BillingAnalyticsService.name); - constructor(private readonly clickHouseService: ClickHouseService) {} async getUsageByUser( @@ -84,9 +92,9 @@ export class BillingAnalyticsService { periodEnd, groupByField, extraWhere = '', - extraParams = {}, + extraParams, }: PeriodParams & { - groupByField: string; + groupByField: GroupByField; extraWhere?: string; extraParams?: Record; }): Promise { @@ -101,21 +109,20 @@ export class BillingAnalyticsService { ${extraWhere} GROUP BY ${groupByField} ORDER BY creditsUsed DESC - LIMIT 50 + LIMIT ${BREAKDOWN_QUERY_LIMIT} `; - const rows = - await this.clickHouseService.select(query, { + const rows = await this.clickHouseService.select( + query, + { workspaceId, periodStart: formatDateForClickHouse(periodStart), periodEnd: formatDateForClickHouse(periodEnd), - ...extraParams, - }); + ...(extraParams ?? {}), + }, + ); - return rows.map((row) => ({ - ...row, - creditsUsed: toDisplayCredits(row.creditsUsed), - })); + return this.mapToDisplayCredits(rows); } private async queryTimeSeries({ @@ -123,7 +130,7 @@ export class BillingAnalyticsService { periodStart, periodEnd, extraWhere = '', - extraParams = {}, + extraParams, }: PeriodParams & { extraWhere?: string; extraParams?: Record; @@ -146,9 +153,15 @@ export class BillingAnalyticsService { workspaceId, periodStart: formatDateForClickHouse(periodStart), periodEnd: formatDateForClickHouse(periodEnd), - ...extraParams, + ...(extraParams ?? {}), }); + return this.mapToDisplayCredits(rows); + } + + private mapToDisplayCredits( + rows: T[], + ): T[] { return rows.map((row) => ({ ...row, creditsUsed: toDisplayCredits(row.creditsUsed), diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-event-writer.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-event-writer.service.ts index 8d97dff9d38..6f219bb4368 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-event-writer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-event-writer.service.ts @@ -57,10 +57,7 @@ export class BillingEventWriterService { })); this.clickHouseService.insert('billingEvent', rows).catch((error) => { - this.logger.error( - 'Failed to write billing events to ClickHouse', - error, - ); + this.logger.error('Failed to write billing events to ClickHouse', error); }); } }