refactor(billing): clean up analytics code, fix security, deduplicate

- Extract shared billing analytics utils (PeriodPreset, getExecutionTypeLabel,
  getPeriodDates, getPeriodOptions, getChartColors) into billing-analytics.utils.ts
- Extract BillingChartTooltip component to replace 4 duplicated inline tooltip styles
- Extract BillingLineChartContainer / BillingPieChartContainer shared styled components
- Fix resourceTotal recomputed inside .map() on every iteration
- Restrict ClickHouse groupByField to union type (SQL injection prevention)
- Remove unused Logger in BillingAnalyticsService
- Extract BREAKDOWN_QUERY_LIMIT constant from magic number
- Deduplicate toDisplayCredits mapping into private mapToDisplayCredits helper
- Extract getDefaultAnalyticsPeriod to eliminate let declarations in resolver
- Remove redundant return statement in validateCanCheckoutSessionPermissionOrThrow
- Validate userWorkspaceId belongs to current workspace before querying (security)
- Scope resolveUserNames by workspaceId to prevent cross-workspace PII leakage
- Split monolithic billing-usage-breakdown.dto.ts into one-export-per-file DTOs
- Merge duplicate FeatureFlagKey import in SettingsBillingContent
- Fix TableCell cursor inheritance for clickable table rows
- Fix SubscriptionInfoContainer box-sizing for layout alignment
- Gate usage analytics UI behind IS_USAGE_ANALYTICS_ENABLED feature flag

Made-with: Cursor
This commit is contained in:
Félix Malfait
2026-03-17 17:37:18 +01:00
parent 7bf3449442
commit 036cf249b3
16 changed files with 368 additions and 402 deletions
@@ -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%;
`;
@@ -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>
);
@@ -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<PeriodPreset, number> = {
'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<PeriodPreset>('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 = () => {
}
/>
<SubscriptionInfoContainer>
<StyledPieChartContainer>
<BillingPieChartContainer>
<ResponsivePie
data={pieData}
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
@@ -292,23 +227,13 @@ export const SettingsBillingAnalyticsSection = () => {
animate
motionConfig={CHART_MOTION_CONFIG}
tooltip={({ datum }) => (
<div
style={{
background: theme.background.primary,
border: `1px solid ${theme.border.color.medium}`,
borderRadius: theme.border.radius.sm,
padding: '6px 10px',
fontSize: theme.font.size.sm,
color: theme.font.color.primary,
boxShadow: theme.boxShadow.light,
}}
>
<strong>{datum.id}</strong>: {formatNumber(datum.value)}{' '}
{t`credits`}
</div>
<BillingChartTooltip
label={String(datum.id)}
value={`${formatNumber(datum.value)} ${t`credits`}`}
/>
)}
/>
</StyledPieChartContainer>
</BillingPieChartContainer>
</SubscriptionInfoContainer>
</Section>
)}
@@ -330,7 +255,7 @@ export const SettingsBillingAnalyticsSection = () => {
}
/>
<SubscriptionInfoContainer>
<StyledChartContainer>
<BillingLineChartContainer>
<ResponsiveLine
data={lineData}
margin={{ top: 10, right: 20, bottom: 30, left: 50 }}
@@ -361,9 +286,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 }) => (
<div
style={{
background: theme.background.primary,
border: `1px solid ${theme.border.color.medium}`,
borderRadius: theme.border.radius.sm,
padding: '6px 10px',
fontSize: theme.font.size.sm,
color: theme.font.color.primary,
boxShadow: theme.boxShadow.light,
}}
>
<strong>{slice.points[0]?.data.xFormatted}</strong>:{' '}
{formatNumber(Number(slice.points[0]?.data.yFormatted))}{' '}
{t`credits`}
</div>
<BillingChartTooltip
label={String(slice.points[0]?.data.xFormatted)}
value={`${formatNumber(Number(slice.points[0]?.data.yFormatted))} ${t`credits`}`}
/>
)}
/>
</StyledChartContainer>
</BillingLineChartContainer>
</SubscriptionInfoContainer>
</Section>
)}
@@ -423,7 +335,7 @@ export const SettingsBillingAnalyticsSection = () => {
onChange={setUserSearchTerm}
/>
</StyledSearchInputContainer>
<StyledClickableTable>
<Table>
<TableRow
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
>
@@ -464,7 +376,7 @@ export const SettingsBillingAnalyticsSection = () => {
</TableCell>
</TableRow>
))}
</StyledClickableTable>
</Table>
</Section>
)}
@@ -486,10 +398,6 @@ export const SettingsBillingAnalyticsSection = () => {
/>
<SubscriptionInfoContainer>
{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 (
<StyledBarRow key={item.key}>
<StyledBarLabel>
<StyledLabelText>
{item.label ?? item.key}
</StyledLabelText>
<StyledLabelText>{item.label ?? item.key}</StyledLabelText>
<StyledValueText>
{formatNumber(item.creditsUsed)} {t`credits`}
</StyledValueText>
@@ -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 = () => {
}
/>
)}
<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>
{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`}
@@ -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]);
@@ -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]};
@@ -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<PeriodPreset, number> = {
'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<PeriodPreset>('30d');
const [typePeriod, setTypePeriod] = useState<PeriodPreset>('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 (
<SubMenuTopBarContainer
title={tLingui`User Usage`}
links={breadcrumbLinks}
>
<SubMenuTopBarContainer title={tLingui`User Usage`} links={breadcrumbLinks}>
<SettingsPageContainer>
<StyledUserHeader>
<Avatar
@@ -300,7 +239,7 @@ export const SettingsUsageUserDetail = () => {
}
/>
<SubscriptionInfoContainer>
<StyledChartContainer>
<BillingLineChartContainer>
<ResponsiveLine
data={lineData}
margin={{ top: 10, right: 20, bottom: 30, left: 50 }}
@@ -331,8 +270,7 @@ export const SettingsUsageUserDetail = () => {
? 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 }) => (
<div
style={{
background: theme.background.primary,
border: `1px solid ${theme.border.color.medium}`,
borderRadius: theme.border.radius.sm,
padding: '6px 10px',
fontSize: theme.font.size.sm,
color: theme.font.color.primary,
boxShadow: theme.boxShadow.light,
}}
>
<strong>{slice.points[0]?.data.xFormatted}</strong>:{' '}
{formatNumber(
Number(slice.points[0]?.data.yFormatted),
)}{' '}
{t`credits`}
</div>
<BillingChartTooltip
label={String(slice.points[0]?.data.xFormatted)}
value={`${formatNumber(Number(slice.points[0]?.data.yFormatted))} ${t`credits`}`}
/>
)}
/>
</StyledChartContainer>
</BillingLineChartContainer>
</SubscriptionInfoContainer>
</Section>
)}
@@ -389,7 +314,7 @@ export const SettingsUsageUserDetail = () => {
}
/>
<SubscriptionInfoContainer>
<StyledPieChartContainer>
<BillingPieChartContainer>
<ResponsivePie
data={pieData}
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
@@ -407,23 +332,13 @@ export const SettingsUsageUserDetail = () => {
animate
motionConfig={CHART_MOTION_CONFIG}
tooltip={({ datum }) => (
<div
style={{
background: theme.background.primary,
border: `1px solid ${theme.border.color.medium}`,
borderRadius: theme.border.radius.sm,
padding: '6px 10px',
fontSize: theme.font.size.sm,
color: theme.font.color.primary,
boxShadow: theme.boxShadow.light,
}}
>
<strong>{datum.id}</strong>: {formatNumber(datum.value)}{' '}
{t`credits`}
</div>
<BillingChartTooltip
label={String(datum.id)}
value={`${formatNumber(datum.value)} ${t`credits`}`}
/>
)}
/>
</StyledPieChartContainer>
</BillingPieChartContainer>
</SubscriptionInfoContainer>
</Section>
)}
@@ -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<BillingAnalyticsDTO> {
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<Map<string, string>> {
const nameMap = new Map<string, string>();
@@ -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) {
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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;
}
@@ -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<string, unknown>;
}): Promise<BillingUsageBreakdownItem[]> {
@@ -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<BillingUsageBreakdownItem>(query, {
const rows = await this.clickHouseService.select<BillingUsageBreakdownItem>(
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<string, unknown>;
@@ -146,9 +153,15 @@ export class BillingAnalyticsService {
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
...extraParams,
...(extraParams ?? {}),
});
return this.mapToDisplayCredits(rows);
}
private mapToDisplayCredits<T extends { creditsUsed: number }>(
rows: T[],
): T[] {
return rows.map((row) => ({
...row,
creditsUsed: toDisplayCredits(row.creditsUsed),
@@ -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);
});
}
}