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:
+2
-2
@@ -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 }),
|
||||
|
||||
+22
@@ -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',
|
||||
}
|
||||
|
||||
+2
-9
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user