Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6cf6e1fa | ||
|
|
3386d929a5 | ||
|
|
a729d59171 | ||
|
|
def9f774c8 | ||
|
|
d9faf5d9f4 | ||
|
|
1c8caf4ac6 | ||
|
|
941a55754c |
@@ -2511,9 +2511,11 @@ export type Mutation = {
|
||||
updatePageLayoutWithTabsAndWidgets: PageLayout;
|
||||
updatePasswordViaResetToken: InvalidatePassword;
|
||||
updateSkill: Skill;
|
||||
updateUserAiChatBudget: Scalars['Boolean'];
|
||||
updateUserEmail: Scalars['Boolean'];
|
||||
updateWebhook: Webhook;
|
||||
updateWorkspace: Workspace;
|
||||
updateWorkspaceDefaultUserAiChatBudget: Scalars['Boolean'];
|
||||
updateWorkspaceFeatureFlag: Scalars['Boolean'];
|
||||
updateWorkspaceMemberRole: WorkspaceMember;
|
||||
upgradeApplication: Scalars['Boolean'];
|
||||
@@ -3342,6 +3344,12 @@ export type MutationUpdateSkillArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateUserAiChatBudgetArgs = {
|
||||
maxCreditsPerPeriod?: InputMaybe<Scalars['Float']>;
|
||||
userWorkspaceId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateUserEmailArgs = {
|
||||
newEmail: Scalars['String'];
|
||||
verifyEmailRedirectPath?: InputMaybe<Scalars['String']>;
|
||||
@@ -3358,6 +3366,11 @@ export type MutationUpdateWorkspaceArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateWorkspaceDefaultUserAiChatBudgetArgs = {
|
||||
maxCreditsPerPeriod?: InputMaybe<Scalars['Float']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateWorkspaceFeatureFlagArgs = {
|
||||
featureFlag: Scalars['String'];
|
||||
value: Scalars['Boolean'];
|
||||
@@ -5298,6 +5311,7 @@ export type UserWorkspace = {
|
||||
deletedAt?: Maybe<Scalars['DateTime']>;
|
||||
id: Scalars['UUID'];
|
||||
locale: Scalars['String'];
|
||||
maxAiChatCreditsPerPeriod?: Maybe<Scalars['Float']>;
|
||||
objectPermissions?: Maybe<Array<ObjectPermission>>;
|
||||
objectsPermissions?: Maybe<Array<ObjectPermission>>;
|
||||
permissionFlags?: Maybe<Array<PermissionFlagType>>;
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS billingEvent
|
||||
(
|
||||
`eventId` UUID DEFAULT generateUUIDv4(),
|
||||
`timestamp` DateTime64(3) NOT NULL,
|
||||
`workspaceId` String NOT NULL,
|
||||
`userWorkspaceId` String DEFAULT '',
|
||||
`eventType` LowCardinality(String) NOT NULL,
|
||||
`creditsUsed` Int64 NOT NULL,
|
||||
`costInMicroDollars` Int64 DEFAULT 0,
|
||||
`resourceType` LowCardinality(String) DEFAULT '',
|
||||
`resourceId` String DEFAULT '',
|
||||
`metadata` JSON
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
ORDER BY (workspaceId, userWorkspaceId, timestamp, eventType)
|
||||
TTL timestamp + INTERVAL 3 YEAR DELETE;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddAiChatCreditsPerPeriod1772706488000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddAiChatCreditsPerPeriod1772706488000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspace" ADD "maxAiChatCreditsPerPeriod" integer`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."workspace" ADD "defaultUserAiChatMaxCreditsPerPeriod" integer`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."workspace" DROP COLUMN "defaultUserAiChatMaxCreditsPerPeriod"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."userWorkspace" DROP COLUMN "maxAiChatCreditsPerPeriod"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export enum BillingExceptionCode {
|
||||
BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND',
|
||||
BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND = 'BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND',
|
||||
BILLING_CREDITS_EXHAUSTED = 'BILLING_CREDITS_EXHAUSTED',
|
||||
BILLING_USER_BUDGET_EXCEEDED = 'BILLING_USER_BUDGET_EXCEEDED',
|
||||
}
|
||||
|
||||
const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => {
|
||||
@@ -86,6 +87,8 @@ const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => {
|
||||
return msg`Multiple subscriptions found where one was expected.`;
|
||||
case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED:
|
||||
return msg`You have exhausted your credits. Please upgrade your plan to continue.`;
|
||||
case BillingExceptionCode.BILLING_USER_BUDGET_EXCEEDED:
|
||||
return msg`You have exceeded your AI chat budget. Please contact your workspace administrator.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/
|
||||
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
|
||||
import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingAnalyticsService } from 'src/engine/core-modules/billing/services/billing-analytics.service';
|
||||
import { BillingBudgetGuardService } from 'src/engine/core-modules/billing/services/billing-budget-guard.service';
|
||||
import { BillingEventWriterService } from 'src/engine/core-modules/billing/services/billing-event-writer.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
|
||||
@@ -38,6 +41,7 @@ import { MessageQueueModule } from 'src/engine/core-modules/message-queue/messag
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
@@ -52,6 +56,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
AiBillingModule,
|
||||
AiModelsModule,
|
||||
WorkspaceDomainsModule,
|
||||
ClickHouseModule,
|
||||
TypeOrmModule.forFeature([
|
||||
BillingSubscriptionEntity,
|
||||
BillingSubscriptionItemEntity,
|
||||
@@ -84,6 +89,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
BillingUpdateSubscriptionPriceCommand,
|
||||
BillingSyncPlansDataCommand,
|
||||
BillingUsageService,
|
||||
BillingEventWriterService,
|
||||
BillingBudgetGuardService,
|
||||
BillingAnalyticsService,
|
||||
BillingPriceService,
|
||||
BillingCreditRolloverService,
|
||||
MeteredCreditService,
|
||||
@@ -96,6 +104,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
BillingPortalWorkspaceService,
|
||||
BillingService,
|
||||
BillingUsageService,
|
||||
BillingEventWriterService,
|
||||
BillingBudgetGuardService,
|
||||
BillingAnalyticsService,
|
||||
BillingCreditRolloverService,
|
||||
MeteredCreditService,
|
||||
],
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
|
||||
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
|
||||
import { BillingUpdateUserAiChatBudgetInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-user-ai-chat-budget.input';
|
||||
import { BillingUpdateWorkspaceDefaultAiChatBudgetInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-workspace-default-ai-chat-budget.input';
|
||||
import { BillingEndTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-end-trial-period.dto';
|
||||
import { BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
|
||||
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
@@ -30,7 +34,8 @@ import {
|
||||
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 { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
@@ -63,6 +68,10 @@ export class BillingResolver {
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
@Query(() => BillingSessionDTO)
|
||||
@@ -338,6 +347,45 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
)
|
||||
async updateUserAiChatBudget(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args()
|
||||
{
|
||||
userWorkspaceId,
|
||||
maxCreditsPerPeriod,
|
||||
}: BillingUpdateUserAiChatBudgetInput,
|
||||
): Promise<boolean> {
|
||||
await this.userWorkspaceRepository.update(
|
||||
{ id: userWorkspaceId, workspaceId: workspace.id },
|
||||
{ maxAiChatCreditsPerPeriod: maxCreditsPerPeriod },
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
)
|
||||
async updateWorkspaceDefaultUserAiChatBudget(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args()
|
||||
{ maxCreditsPerPeriod }: BillingUpdateWorkspaceDefaultAiChatBudgetInput,
|
||||
): Promise<boolean> {
|
||||
await this.workspaceRepository.update(
|
||||
{ id: workspace.id },
|
||||
{ defaultUserAiChatMaxCreditsPerPeriod: maxCreditsPerPeriod },
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async validateCanCheckoutSessionPermissionOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ArgsType()
|
||||
export class BillingUpdateUserAiChatBudgetInput {
|
||||
@Field(() => UUIDScalarType)
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
userWorkspaceId: string;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxCreditsPerPeriod: number | null;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class BillingUpdateWorkspaceDefaultAiChatBudgetInput {
|
||||
@Field(() => Number, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxCreditsPerPeriod: number | null;
|
||||
}
|
||||
+6
-4
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingEventWriterService } from 'src/engine/core-modules/billing/services/billing-event-writer.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@@ -15,6 +16,7 @@ import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/ty
|
||||
export class BillingFeatureUsedListener {
|
||||
constructor(
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly billingEventWriterService: BillingEventWriterService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
@@ -38,9 +40,9 @@ export class BillingFeatureUsedListener {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.billingUsageService.billUsage({
|
||||
workspaceId: payload.workspaceId,
|
||||
billingEvents: payload.events,
|
||||
});
|
||||
await this.billingEventWriterService.writeBillingEvents(
|
||||
payload.workspaceId,
|
||||
payload.events,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
type UserWorkspaceUsageRow = {
|
||||
userWorkspaceId: string;
|
||||
eventType: string;
|
||||
totalCredits: string;
|
||||
eventCount: string;
|
||||
};
|
||||
|
||||
type ResourceUsageRow = {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
totalCredits: string;
|
||||
};
|
||||
|
||||
export type UserWorkspaceUsage = {
|
||||
userWorkspaceId: string;
|
||||
eventType: string;
|
||||
totalCredits: number;
|
||||
eventCount: number;
|
||||
};
|
||||
|
||||
export type ResourceUsage = {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
totalCredits: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BillingAnalyticsService {
|
||||
constructor(
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async getWorkspaceTotalUsage(
|
||||
workspaceId: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<number> {
|
||||
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.clickHouseService.select<{ total: string }>(
|
||||
`SELECT SUM(creditsUsed) as total FROM billingEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:DateTime64}
|
||||
AND timestamp < {periodEnd:DateTime64}`,
|
||||
{
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
},
|
||||
);
|
||||
|
||||
return Number(result[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
async getUserWorkspaceUsageBreakdown(
|
||||
workspaceId: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
userWorkspaceId?: string,
|
||||
): Promise<UserWorkspaceUsage[]> {
|
||||
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userFilter = userWorkspaceId
|
||||
? 'AND userWorkspaceId = {userWorkspaceId:String}'
|
||||
: '';
|
||||
|
||||
const rows = await this.clickHouseService.select<UserWorkspaceUsageRow>(
|
||||
`SELECT
|
||||
userWorkspaceId,
|
||||
eventType,
|
||||
SUM(creditsUsed) as totalCredits,
|
||||
COUNT(*) as eventCount
|
||||
FROM billingEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:DateTime64}
|
||||
AND timestamp < {periodEnd:DateTime64}
|
||||
${userFilter}
|
||||
GROUP BY userWorkspaceId, eventType`,
|
||||
{
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...(userWorkspaceId ? { userWorkspaceId } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
userWorkspaceId: row.userWorkspaceId,
|
||||
eventType: row.eventType,
|
||||
totalCredits: Number(row.totalCredits),
|
||||
eventCount: Number(row.eventCount),
|
||||
}));
|
||||
}
|
||||
|
||||
async getResourceUsageBreakdown(
|
||||
workspaceId: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<ResourceUsage[]> {
|
||||
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.clickHouseService.select<ResourceUsageRow>(
|
||||
`SELECT
|
||||
resourceId,
|
||||
resourceType,
|
||||
SUM(creditsUsed) as totalCredits
|
||||
FROM billingEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:DateTime64}
|
||||
AND timestamp < {periodEnd:DateTime64}
|
||||
AND resourceId != ''
|
||||
GROUP BY resourceId, resourceType`,
|
||||
{
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
},
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
resourceId: row.resourceId,
|
||||
resourceType: row.resourceType,
|
||||
totalCredits: Number(row.totalCredits),
|
||||
}));
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { buildUserUsageCacheKey } from 'src/engine/core-modules/billing/utils/build-user-usage-cache-key.util';
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
const USER_USAGE_CACHE_TTL_MS = 60_000;
|
||||
|
||||
@Injectable()
|
||||
export class BillingBudgetGuardService {
|
||||
constructor(
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
) {}
|
||||
|
||||
async canUserSpend(
|
||||
workspaceId: string,
|
||||
userWorkspaceId: string,
|
||||
): Promise<boolean> {
|
||||
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const maxCredits = await this.resolveEffectiveMaxCredits(
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
if (maxCredits === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentUsage = await this.getUserPeriodUsage(
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
return currentUsage < maxCredits;
|
||||
}
|
||||
|
||||
private async getUserPeriodUsage(
|
||||
workspaceId: string,
|
||||
userWorkspaceId: string,
|
||||
): Promise<number> {
|
||||
const cacheKey = buildUserUsageCacheKey(workspaceId, userWorkspaceId);
|
||||
const cached = await this.cacheStorage.get<number>(cacheKey);
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const periodStart = await this.getCurrentPeriodStart(workspaceId);
|
||||
|
||||
const result = await this.clickHouseService.select<{ total: string }>(
|
||||
`SELECT SUM(creditsUsed) as total FROM billingEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND userWorkspaceId = {userWorkspaceId:String}
|
||||
AND timestamp >= {periodStart:DateTime64}`,
|
||||
{
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
},
|
||||
);
|
||||
|
||||
const usage = Number(result[0]?.total ?? 0);
|
||||
|
||||
await this.cacheStorage.set(cacheKey, usage, USER_USAGE_CACHE_TTL_MS);
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
private async resolveEffectiveMaxCredits(
|
||||
workspaceId: string,
|
||||
userWorkspaceId: string,
|
||||
): Promise<number | null> {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: userWorkspaceId, workspaceId },
|
||||
select: ['maxAiChatCreditsPerPeriod'],
|
||||
});
|
||||
|
||||
if (isDefined(userWorkspace?.maxAiChatCreditsPerPeriod)) {
|
||||
return userWorkspace.maxAiChatCreditsPerPeriod;
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: { id: workspaceId },
|
||||
select: ['defaultUserAiChatMaxCreditsPerPeriod'],
|
||||
});
|
||||
|
||||
return workspace?.defaultUserAiChatMaxCreditsPerPeriod ?? null;
|
||||
}
|
||||
|
||||
private async getCurrentPeriodStart(workspaceId: string): Promise<Date> {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscription({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return new Date(0);
|
||||
}
|
||||
|
||||
const isTrialing =
|
||||
subscription.status === SubscriptionStatus.Trialing &&
|
||||
isDefined(subscription.trialStart);
|
||||
|
||||
return isTrialing
|
||||
? subscription.trialStart!
|
||||
: subscription.currentPeriodStart;
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type BillingExecutionType } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { buildUserUsageCacheKey } from 'src/engine/core-modules/billing/utils/build-user-usage-cache-key.util';
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
|
||||
import { BillingUsageService } from './billing-usage.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingEventWriterService {
|
||||
private readonly logger = new Logger(BillingEventWriterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
) {}
|
||||
|
||||
async writeBillingEvents(
|
||||
workspaceId: string,
|
||||
events: BillingUsageEvent[],
|
||||
): Promise<void> {
|
||||
if (this.twentyConfigService.get('CLICKHOUSE_URL')) {
|
||||
this.writeToClickHouse(workspaceId, events).catch((error) => {
|
||||
this.logger.error(
|
||||
'Failed to write billing events to ClickHouse',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
this.invalidateUserUsageCache(workspaceId, events);
|
||||
}
|
||||
|
||||
await this.billingUsageService.billUsage({
|
||||
workspaceId,
|
||||
billingEvents: events,
|
||||
});
|
||||
}
|
||||
|
||||
private async writeToClickHouse(
|
||||
workspaceId: string,
|
||||
events: BillingUsageEvent[],
|
||||
): Promise<void> {
|
||||
const rows = events.map((event) => ({
|
||||
timestamp: new Date(),
|
||||
workspaceId,
|
||||
userWorkspaceId: event.userWorkspaceId ?? '',
|
||||
eventType: event.dimensions?.execution_type ?? '',
|
||||
creditsUsed: event.value,
|
||||
resourceType: this.mapExecutionTypeToResourceType(
|
||||
event.dimensions?.execution_type,
|
||||
),
|
||||
resourceId: event.dimensions?.resource_id ?? '',
|
||||
metadata: {
|
||||
executionContext1: event.dimensions?.execution_context_1 ?? null,
|
||||
eventName: event.eventName,
|
||||
},
|
||||
}));
|
||||
|
||||
await this.clickHouseService.insert('billingEvent', rows);
|
||||
}
|
||||
|
||||
private invalidateUserUsageCache(
|
||||
workspaceId: string,
|
||||
events: BillingUsageEvent[],
|
||||
): void {
|
||||
const userWorkspaceIds = new Set(
|
||||
events.map((event) => event.userWorkspaceId).filter(isDefined),
|
||||
);
|
||||
|
||||
for (const userWorkspaceId of userWorkspaceIds) {
|
||||
this.cacheStorage
|
||||
.del(buildUserUsageCacheKey(workspaceId, userWorkspaceId))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private mapExecutionTypeToResourceType(
|
||||
executionType: BillingExecutionType | undefined,
|
||||
): string {
|
||||
switch (executionType) {
|
||||
case 'ai_token':
|
||||
return 'agent';
|
||||
case 'workflow_execution':
|
||||
return 'workflow';
|
||||
case 'app_invocation':
|
||||
return 'app';
|
||||
case 'code_execution':
|
||||
return 'code';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
export type BillingExecutionType =
|
||||
| 'workflow_execution'
|
||||
| 'code_execution'
|
||||
| 'ai_token';
|
||||
| 'ai_token'
|
||||
| 'app_invocation';
|
||||
|
||||
export type BillingDimensions = {
|
||||
execution_type: BillingExecutionType;
|
||||
|
||||
+1
@@ -9,4 +9,5 @@ export type BillingUsageEvent = {
|
||||
eventName: BillingMeterEventName;
|
||||
value: NonNegative<number>;
|
||||
dimensions?: BillingDimensions;
|
||||
userWorkspaceId?: string;
|
||||
};
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export const buildUserUsageCacheKey = (
|
||||
workspaceId: string,
|
||||
userWorkspaceId: string,
|
||||
): string => `billing:user-usage:${workspaceId}:${userWorkspaceId}`;
|
||||
@@ -82,6 +82,10 @@ export class UserWorkspaceEntity extends WorkspaceRelatedEntity {
|
||||
@DeleteDateColumn({ type: 'timestamptz' })
|
||||
deletedAt: Date;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
@Column({ type: 'int', nullable: true, default: null })
|
||||
maxAiChatCreditsPerPeriod: number | null;
|
||||
|
||||
@OneToMany(
|
||||
() => TwoFactorAuthenticationMethodEntity,
|
||||
(twoFactorAuthenticationMethod) =>
|
||||
|
||||
@@ -122,6 +122,9 @@ export class WorkspaceEntity {
|
||||
@Column({ type: 'integer', default: 90 })
|
||||
eventLogRetentionDays: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true, default: null })
|
||||
defaultUserAiChatMaxCreditsPerPeriod: number | null;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => AppTokenEntity, (appToken) => appToken.workspace, {
|
||||
cascade: true,
|
||||
|
||||
+30
@@ -332,6 +332,7 @@ describe('AIBillingService', () => {
|
||||
{ usage: mockTokenUsage },
|
||||
'workspace-1',
|
||||
'agent-id-123',
|
||||
'user-workspace-456',
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -347,6 +348,35 @@ describe('AIBillingService', () => {
|
||||
resource_id: 'agent-id-123',
|
||||
execution_context_1: 'gpt-4o',
|
||||
},
|
||||
userWorkspaceId: 'user-workspace-456',
|
||||
},
|
||||
],
|
||||
'workspace-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit billing event without userWorkspaceId when not provided', () => {
|
||||
service.calculateAndBillUsage(
|
||||
'gpt-4o',
|
||||
{ usage: mockTokenUsage },
|
||||
'workspace-1',
|
||||
'agent-id-123',
|
||||
);
|
||||
|
||||
expect(
|
||||
mockWorkspaceEventEmitter.emitCustomBatchEvent,
|
||||
).toHaveBeenCalledWith(
|
||||
BILLING_FEATURE_USED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: 7500,
|
||||
dimensions: {
|
||||
execution_type: 'ai_token',
|
||||
resource_id: 'agent-id-123',
|
||||
execution_context_1: 'gpt-4o',
|
||||
},
|
||||
userWorkspaceId: undefined,
|
||||
},
|
||||
],
|
||||
'workspace-1',
|
||||
|
||||
+10
-1
@@ -59,13 +59,20 @@ export class AIBillingService {
|
||||
billingInput: BillingUsageInput,
|
||||
workspaceId: string,
|
||||
agentId?: string | null,
|
||||
userWorkspaceId?: string,
|
||||
): void {
|
||||
const costInDollars = this.calculateCost(modelId, billingInput);
|
||||
const creditsUsed = Math.round(
|
||||
convertDollarsToBillingCredits(costInDollars),
|
||||
);
|
||||
|
||||
this.sendAiTokenUsageEvent(workspaceId, creditsUsed, modelId, agentId);
|
||||
this.sendAiTokenUsageEvent(
|
||||
workspaceId,
|
||||
creditsUsed,
|
||||
modelId,
|
||||
agentId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private sendAiTokenUsageEvent(
|
||||
@@ -73,6 +80,7 @@ export class AIBillingService {
|
||||
creditsUsed: number,
|
||||
modelId: ModelId,
|
||||
agentId?: string | null,
|
||||
userWorkspaceId?: string,
|
||||
): void {
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
|
||||
BILLING_FEATURE_USED,
|
||||
@@ -85,6 +93,7 @@ export class AIBillingService {
|
||||
resource_id: agentId || null,
|
||||
execution_context_1: modelId,
|
||||
},
|
||||
userWorkspaceId,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
|
||||
+14
@@ -19,6 +19,7 @@ import {
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
|
||||
import { BillingBudgetGuardService } from 'src/engine/core-modules/billing/services/billing-budget-guard.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -47,6 +48,7 @@ export class AgentChatController {
|
||||
constructor(
|
||||
private readonly agentStreamingService: AgentChatStreamingService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingBudgetGuardService: BillingBudgetGuardService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
) {}
|
||||
@@ -90,6 +92,18 @@ export class AgentChatController {
|
||||
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
|
||||
);
|
||||
}
|
||||
|
||||
const canUserSpend = await this.billingBudgetGuardService.canUserSpend(
|
||||
workspace.id,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
if (!canUserSpend) {
|
||||
throw new BillingException(
|
||||
'User AI chat budget exceeded',
|
||||
BillingExceptionCode.BILLING_USER_BUDGET_EXCEEDED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.agentStreamingService.streamAgentChat({
|
||||
|
||||
+1
@@ -243,6 +243,7 @@ export class ChatExecutionService {
|
||||
{ usage, cacheCreationTokens },
|
||||
workspace.id,
|
||||
null,
|
||||
userWorkspaceId,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
+6
@@ -84,11 +84,17 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
authContext: executionContext.authContext,
|
||||
});
|
||||
|
||||
const userWorkspaceId =
|
||||
executionContext.authContext.type === 'user'
|
||||
? executionContext.authContext.userWorkspaceId
|
||||
: undefined;
|
||||
|
||||
await this.aiBillingService.calculateAndBillUsage(
|
||||
agent?.modelId ?? DEFAULT_SMART_MODEL,
|
||||
{ usage, cacheCreationTokens },
|
||||
workspaceId,
|
||||
agent?.id || null,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user