Billing - fixes (#19867)

- Uniformize credit formating : In UI, 1$=1credit. In BE 1 UI credit =
1_000_000 BE "crédits"
- Add crédit rollover information + Link to documentation +
Documentation update
<img width="291" height="317" alt="Screenshot 2026-04-17 at 18 22 59"
src="https://github.com/user-attachments/assets/2519fb9f-159d-4c85-95f4-a6e005a8a1a3"
/>
<img width="848" height="763" alt="Screenshot 2026-04-17 at 14 12 20"
src="https://github.com/user-attachments/assets/a3cc0874-f275-49ea-819f-305ec314bdfe"
/>
<img width="797" height="757" alt="Screenshot 2026-04-17 at 14 12 13"
src="https://github.com/user-attachments/assets/9048409b-d5a2-435a-b735-70370705e668"
/>

- Enable direct top-up (or subscription if in trial) from AI chat
<img width="333" height="215" alt="Screenshot 2026-04-17 at 22 52 00"
src="https://github.com/user-attachments/assets/7a20c627-2806-4bcf-a037-b45752232be9"
/>
<img width="457" height="769" alt="Screenshot 2026-04-17 at 22 51 41"
src="https://github.com/user-attachments/assets/d2a90c1b-271f-4fe9-8891-baeb2fabb86d"
/>

- Inform users if credit limit is reached - Banner
<img width="1130" height="127" alt="Screenshot 2026-04-17 at 19 15 11"
src="https://github.com/user-attachments/assets/30723e5e-c07e-462f-8eb8-e08f52bbab1c"
/>
This commit is contained in:
Etienne
2026-04-20 10:43:02 +02:00
committed by GitHub
parent 903ae5cb09
commit e68842c268
45 changed files with 521 additions and 151 deletions
@@ -6,4 +6,5 @@ export enum UsageResourceType {
APP = 'APP',
STORAGE = 'STORAGE',
API = 'API',
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
}
@@ -33,7 +33,6 @@ type PeriodParams = {
periodStart: Date;
periodEnd: Date;
operationTypes?: string[];
useDollarMode?: boolean;
};
const ALLOWED_GROUP_BY_FIELDS = [
@@ -137,7 +136,6 @@ export class UsageAnalyticsService {
periodEnd,
groupByField,
operationTypes,
useDollarMode = false,
extraWhere = '',
extraParams,
}: PeriodParams & {
@@ -173,8 +171,6 @@ export class UsageAnalyticsService {
LIMIT ${BREAKDOWN_QUERY_LIMIT}
`;
const convert = useDollarMode ? toDollars : toDisplayCredits;
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
@@ -187,7 +183,7 @@ export class UsageAnalyticsService {
return rows.map((row) => ({
key: row.key,
creditsUsed: convert(row.creditsUsedMicro),
creditsUsed: row.creditsUsedMicro,
}));
}
@@ -196,7 +192,6 @@ export class UsageAnalyticsService {
periodStart,
periodEnd,
operationTypes,
useDollarMode = false,
extraWhere = '',
extraParams,
}: PeriodParams & {
@@ -222,8 +217,6 @@ export class UsageAnalyticsService {
ORDER BY date ASC
`;
const convert = useDollarMode ? toDollars : toDisplayCredits;
const rows = await this.clickHouseService.select<TimeSeriesRowMicro>(
query,
{
@@ -239,7 +232,7 @@ export class UsageAnalyticsService {
return rows.map((row) => ({
date: row.date,
creditsUsed: convert(row.creditsUsedMicro),
creditsUsed: row.creditsUsedMicro,
}));
}
}
@@ -8,21 +8,21 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { In, type Repository } from 'typeorm';
import { UsageAnalyticsDTO } from 'src/engine/core-modules/usage/dtos/usage-analytics.dto';
import { UsageAnalyticsInput } from 'src/engine/core-modules/usage/dtos/inputs/usage-analytics.input';
import {
type UsageBreakdownItem,
UsageAnalyticsService,
} from 'src/engine/core-modules/usage/services/usage-analytics.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UsageAnalyticsInput } from 'src/engine/core-modules/usage/dtos/inputs/usage-analytics.input';
import { UsageAnalyticsDTO } from 'src/engine/core-modules/usage/dtos/usage-analytics.dto';
import {
UsageAnalyticsService,
type UsageBreakdownItem,
} from 'src/engine/core-modules/usage/services/usage-analytics.service';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
@MetadataResolver()
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@@ -30,7 +30,6 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
export class UsageResolver {
constructor(
private readonly usageAnalyticsService: UsageAnalyticsService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
@@ -51,14 +50,12 @@ export class UsageResolver {
const periodStart = input?.periodStart ?? defaultPeriodStart;
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
const useDollarMode = !this.twentyConfigService.get('IS_BILLING_ENABLED');
const periodParams = {
workspaceId: workspace.id,
periodStart,
periodEnd,
operationTypes: input?.operationTypes ?? undefined,
useDollarMode,
};
const [usageByUser, usageByOperationType, usageByModel, timeSeries] =
@@ -78,10 +75,22 @@ export class UsageResolver {
);
const result: UsageAnalyticsDTO = {
usageByUser: resolvedUsageByUser,
usageByOperationType,
usageByModel,
timeSeries,
usageByUser: resolvedUsageByUser.map((item) => ({
...item,
creditsUsed: toDisplayCredits(item.creditsUsed),
})),
usageByOperationType: usageByOperationType.map((item) => ({
...item,
creditsUsed: toDisplayCredits(item.creditsUsed),
})),
usageByModel: usageByModel.map((item) => ({
...item,
creditsUsed: toDisplayCredits(item.creditsUsed),
})),
timeSeries: timeSeries.map((point) => ({
...point,
creditsUsed: toDisplayCredits(point.creditsUsed),
})),
periodStart,
periodEnd,
};
@@ -101,7 +110,10 @@ export class UsageResolver {
result.userDailyUsage = {
userWorkspaceId: input.userWorkspaceId,
dailyUsage,
dailyUsage: dailyUsage.map((point) => ({
...point,
creditsUsed: toDisplayCredits(point.creditsUsed),
})),
};
}
}
@@ -1,9 +1,6 @@
// Internal credits use micro-precision: $1 = 1,000,000 internal credits
// Display credits are 1000x coarser: $1 = 1,000 display credits
// This mirrors the "micro" pattern in payment systems (e.g. microdollars → dollars)
export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1000;
export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1_000_000;
// Converts internal (high-precision) credits to user-facing display credits.
// Rounds to 1 decimal place for clean display (e.g. 7500 → 7.5).
export const toDisplayCredits = (internalCredits: number): number =>
Math.round((internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT) * 10) / 10;
internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT;