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
@@ -99,7 +99,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
prices: [
{
tiers: [
{ upTo: 10000, flatAmount: 500, unitAmount: null },
{ upTo: 10, flatAmount: 500, unitAmount: null },
{ upTo: null, flatAmount: null, unitAmount: 0.001 },
],
recurringInterval: SubscriptionInterval.Month,
@@ -150,7 +150,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
const meteredPrices = result.meteredProducts[0].prices;
expect(meteredPrices![0].tiers[0]).toEqual(
expect.objectContaining({ upTo: 50000 }),
expect.objectContaining({ upTo: 50 }),
);
expect(meteredPrices![0].tiers[1]).toEqual(
expect.objectContaining({ upTo: null }),
@@ -26,11 +26,17 @@ import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-functi
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { cleanServerUrl } from 'src/utils/clean-server-url';
export class LogicFunctionExecutionException extends Error {
@@ -62,6 +68,7 @@ export class LogicFunctionExecutorService {
private readonly subscriptionService: SubscriptionService,
private readonly auditService: AuditService,
private readonly applicationLogsService: ApplicationLogsService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
) {}
async execute({
@@ -274,5 +281,20 @@ export class LogicFunctionExecutorService {
functionId: flatLogicFunction.id,
functionName: flatLogicFunction.name,
});
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
{
resourceType: UsageResourceType.LOGIC_FUNCTION,
operationType: UsageOperationType.CODE_EXECUTION,
creditsUsedMicro: 1,
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: flatLogicFunction.id,
},
],
workspaceId,
);
}
}
@@ -20,7 +20,6 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { ApplicationLogDriver } from 'src/engine/core-modules/application-logs/interfaces/application-log-driver.enum';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces';
@@ -43,6 +42,7 @@ import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import { loadDefaultModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util';
@@ -783,7 +783,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description: 'Amount of credits for the free trial without credit card',
description:
'Amount of credits for the free trial without credit card (in microCredits)',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@@ -792,7 +793,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description: 'Amount of credits for the free trial with credit card',
description:
'Amount of credits for the free trial with credit card (in microCredits)',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@@ -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;