feat(billing): add real-time user budget enforcement via ClickHouse

- Create BillingBudgetGuardService that queries ClickHouse with short-lived Redis cache
- Wire user-level budget check into AgentChatController alongside existing workspace check
- Add BILLING_USER_BUDGET_EXCEEDED exception code with user-friendly message
- Budget resolution: user override -> workspace default -> no cap (null)
- Graceful degradation: skip check if ClickHouse is not configured
- Cache invalidation handled by BillingEventWriterService on write

Co-authored-by: felix <felix@twenty.com>
This commit is contained in:
Cursor Agent
2026-03-05 13:46:52 +00:00
co-authored by felix
parent 1c8caf4ac6
commit d9faf5d9f4
3 changed files with 150 additions and 0 deletions
@@ -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);
}
@@ -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;
}
}
@@ -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({