Add ClickHouse-backed metered credit cap enforcement (#19586)

## Summary
Implements a ClickHouse-backed polling system to enforce metered-credit
caps for workflow executions, replacing reliance on Stripe billing
alerts. The system re-evaluates tier caps against live pricing on every
poll cycle, allowing price/tier changes to propagate immediately without
recreating Stripe alert objects.

## Key Changes

- **BillingUsageCapService**: New service that queries ClickHouse for
current-period credit usage and evaluates whether a subscription has
reached its metered-credit allowance (tier cap + credit balance)
  - `isClickHouseEnabled()`: Checks if ClickHouse is configured
- `getCurrentPeriodCreditsUsed()`: Sums creditsUsedMicro from usageEvent
table for a workspace within a billing period
- `evaluateCap()`: Determines if usage has reached the allowance by
reading live pricing from the subscription

- **EnforceUsageCapJob**: Cron job that polls all active subscriptions
and updates `hasReachedCurrentPeriodCap` on metered items
  - Runs every 2 minutes to keep cap enforcement in sync with live usage
- Supports shadow mode (log-only) via
`BILLING_USAGE_CAP_CLICKHOUSE_ENABLED` flag for safe rollout
- Continues processing after per-subscription errors with detailed
logging

- **EnforceUsageCapCronCommand**: CLI command to register the
enforcement cron job

- **MeteredCreditService**: Extracted
`extractMeteredPricingInfoFromSubscription()` as a pure function for
callers that already hold the subscription with pricing loaded, avoiding
redundant DB queries

- **Configuration**: Added `BILLING_USAGE_CAP_CLICKHOUSE_ENABLED` flag
to control enforcement mode (active vs. shadow)

- **Constants**: Added `METERED_OPERATION_TYPES` to define which
operation types count toward the metered product's credit cap

## Implementation Details

- The service queries ClickHouse for the sum of `creditsUsedMicro` in
the current billing period, matching Stripe meter semantics
- Pricing is re-read on every evaluation, so tier changes propagate
within one poll cycle without Stripe alert recreation
- The cron job only updates the database when the cap state actually
changes (no-op if already in the correct state)
- Shadow mode allows safe validation before enabling enforcement;
transitions are logged but not persisted
- Comprehensive test coverage for both the service and cron job,
including error handling and state transitions

https://claude.ai/code/session_01VksTSrYLXJVCPVBQhQdBTe

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-13 21:17:41 +02:00
committed by GitHub
parent fa354b4c1c
commit 455022f652
18 changed files with 1470 additions and 4 deletions
@@ -5,6 +5,7 @@ import { Command, CommandRunner, Option } from 'nest-commander';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
import { EnforceUsageCapCronCommand } from 'src/engine/core-modules/billing/crons/commands/enforce-usage-cap.cron.command';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
@@ -62,6 +63,7 @@ export class CronRegisterAllCommand extends CommandRunner {
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand,
private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand,
private readonly enforceUsageCapCronCommand: EnforceUsageCapCronCommand,
) {
super();
}
@@ -187,6 +189,10 @@ export class CronRegisterAllCommand extends CommandRunner {
name: 'StaleRegistrationCleanup',
command: this.staleRegistrationCleanupCronCommand,
},
{
name: 'EnforceUsageCap',
command: this.enforceUsageCapCronCommand,
},
];
const commands = this.devMode
@@ -17,6 +17,7 @@ import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
import { StaleRegistrationCleanupModule } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/stale-registration-cleanup.module';
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
import { EnforceUsageCapCronCommand } from 'src/engine/core-modules/billing/crons/commands/enforce-usage-cap.cron.command';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module';
@@ -86,6 +87,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
ListOrphanedWorkspaceEntitiesCommand,
EnterpriseKeyValidationCronCommand,
GenerateApiKeyCommand,
EnforceUsageCapCronCommand,
],
})
export class DatabaseCommandModule {}
@@ -0,0 +1,29 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.22.0', 1776078919203)
export class AddCreditBalanceToBillingCustomerFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
const tableExists = await queryRunner.query(
`SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'billingCustomer'`,
);
if (tableExists.length === 0) {
return;
}
await queryRunner.query(
`ALTER TABLE "core"."billingCustomer" ADD COLUMN IF NOT EXISTS "creditBalanceMicro" bigint NOT NULL DEFAULT 0`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingCustomer" DROP COLUMN IF EXISTS "creditBalanceMicro"`,
);
}
}
@@ -7,6 +7,7 @@ import { AddPermissionFlagRoleIdIndexFastInstanceCommand } from 'src/database/co
import { AddWorkspaceIdToIndirectEntitiesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775758621017-add-workspace-id-to-indirect-entities';
import { AddWorkspaceIdIndexesAndFksFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775761294897-add-workspace-id-indexes-and-fks-to-indirect-entities';
import { DropObjectMetadataDataSourceFkFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1775804361516-drop-object-metadata-data-source-fk';
import { AddCreditBalanceToBillingCustomerFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-fast-1776078919203-add-credit-balance-to-billing-customer';
import { BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-slow-1775758621018-backfill-workspace-id-on-indirect-entities';
import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1785000000000-drop-workspace-version-column';
@@ -19,5 +20,6 @@ export const INSTANCE_COMMANDS = [
BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand,
AddWorkspaceIdIndexesAndFksFastInstanceCommand,
DropObjectMetadataDataSourceFkFastInstanceCommand,
AddCreditBalanceToBillingCustomerFastInstanceCommand,
DropWorkspaceVersionColumnFastInstanceCommand,
];
@@ -1,9 +1,12 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
@@ -12,6 +15,8 @@ export class BillingWebhookCreditGrantService {
constructor(
private readonly meteredCreditService: MeteredCreditService,
private readonly billingSubscriptionService: BillingSubscriptionService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
) {}
async processStripeEvent(stripeCustomerId: string): Promise<void> {
@@ -24,6 +29,22 @@ export class BillingWebhookCreditGrantService {
return;
}
const meteredPricingInfo =
await this.meteredCreditService.getMeteredPricingInfo(subscription.id);
if (isDefined(meteredPricingInfo)) {
const creditBalanceMicro =
await this.meteredCreditService.getCreditBalance(
stripeCustomerId,
meteredPricingInfo.unitPriceCents,
);
await this.billingCustomerRepository.update(
{ stripeCustomerId },
{ creditBalanceMicro },
);
}
await this.meteredCreditService.recreateBillingAlertForSubscription(
subscription,
);
@@ -3,12 +3,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BillingGaugeService } from 'src/engine/core-modules/billing/billing-gauge.service';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
import { BillingSyncPlansDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-plans-data.command';
import { BillingUpdateSubscriptionPriceCommand } from 'src/engine/core-modules/billing/commands/billing-update-subscription-price.command';
import { EnforceUsageCapCronCommand } from 'src/engine/core-modules/billing/crons/commands/enforce-usage-cap.cron.command';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingMeterEntity } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
@@ -28,6 +30,7 @@ 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 { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.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';
@@ -46,6 +49,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
@Module({
imports: [
ClickHouseModule,
FeatureFlagModule,
StripeModule,
MessageQueueModule,
@@ -86,10 +90,12 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
BillingUpdateSubscriptionPriceCommand,
BillingSyncPlansDataCommand,
BillingUsageService,
BillingUsageCapService,
BillingPriceService,
BillingCreditRolloverService,
MeteredCreditService,
BillingGaugeService,
EnforceUsageCapCronCommand,
],
exports: [
BillingSubscriptionService,
@@ -98,8 +104,10 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
BillingPortalWorkspaceService,
BillingService,
BillingUsageService,
BillingUsageCapService,
BillingCreditRolloverService,
MeteredCreditService,
EnforceUsageCapCronCommand,
],
})
export class BillingModule {}
@@ -0,0 +1,365 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { In } from 'typeorm';
import { EnforceUsageCapJob } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.job';
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('EnforceUsageCapJob', () => {
let job: EnforceUsageCapJob;
let idQueryMock: jest.Mock;
let fullQueryMock: jest.Mock;
let billingSubscriptionItemRepository: jest.Mocked<{
update: jest.Mock;
}>;
let billingUsageCapService: jest.Mocked<BillingUsageCapService>;
let twentyConfigService: jest.Mocked<TwentyConfigService>;
const buildSubscription = ({
id = 'sub_123',
workspaceId = 'workspace_123',
itemId = 'item_123',
hasReachedCurrentPeriodCap = false,
stripeCustomerId = 'cus_123',
creditBalanceMicro = 0,
} = {}) =>
({
id,
workspaceId,
stripeCustomerId,
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
currentPeriodEnd: new Date('2026-05-01T00:00:00Z'),
billingCustomer: {
stripeCustomerId,
creditBalanceMicro,
},
billingSubscriptionItems: [
{
id: itemId,
hasReachedCurrentPeriodCap,
billingProduct: {
metadata: {
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
},
},
},
],
}) as unknown as BillingSubscriptionEntity;
beforeEach(async () => {
idQueryMock = jest.fn().mockResolvedValue([]);
fullQueryMock = jest.fn().mockResolvedValue([]);
const idQueryBuilderMock = {
select: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
getRawMany: idQueryMock,
};
const fullQueryBuilderMock = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: fullQueryMock,
};
const createQueryBuilder = jest
.fn()
.mockReturnValueOnce(idQueryBuilderMock)
.mockReturnValueOnce(fullQueryBuilderMock);
const module: TestingModule = await Test.createTestingModule({
providers: [
EnforceUsageCapJob,
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: { createQueryBuilder },
},
{
provide: getRepositoryToken(BillingSubscriptionItemEntity),
useValue: { update: jest.fn() },
},
{
provide: BillingUsageCapService,
useValue: {
isClickHouseEnabled: jest.fn().mockReturnValue(true),
getBatchPeriodCreditsUsed: jest.fn().mockResolvedValue(new Map()),
evaluateCapBatch: jest.fn().mockReturnValue(new Map()),
},
},
{
provide: TwentyConfigService,
useValue: { get: jest.fn() },
},
],
}).compile();
job = module.get<EnforceUsageCapJob>(EnforceUsageCapJob);
billingSubscriptionItemRepository = module.get(
getRepositoryToken(BillingSubscriptionItemEntity),
);
billingUsageCapService = module.get(BillingUsageCapService);
twentyConfigService = module.get(TwentyConfigService);
});
afterEach(() => {
jest.clearAllMocks();
});
const mockConfig = (overrides: Record<string, unknown> = {}) => {
const values: Record<string, unknown> = {
IS_BILLING_ENABLED: true,
BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: false,
...overrides,
};
twentyConfigService.get.mockImplementation(
(key: string) => values[key] as never,
);
};
it('no-ops when billing is disabled', async () => {
mockConfig({ IS_BILLING_ENABLED: false });
await job.handle();
expect(idQueryMock).not.toHaveBeenCalled();
});
it('no-ops when ClickHouse is not configured', async () => {
mockConfig();
billingUsageCapService.isClickHouseEnabled.mockReturnValue(false);
await job.handle();
expect(idQueryMock).not.toHaveBeenCalled();
});
it('skips transitions in shadow mode (flag off)', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: false });
const sub = buildSubscription({ hasReachedCurrentPeriodCap: false });
idQueryMock.mockResolvedValueOnce([{ subscription_id: 'sub_123' }]);
fullQueryMock.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map([['workspace_123', 2_000_000]]),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: true,
usage: 2_000_000,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
it('batch-updates hasReachedCurrentPeriodCap=true in active mode when usage exceeds allowance', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({
itemId: 'item_123',
hasReachedCurrentPeriodCap: false,
});
idQueryMock.mockResolvedValueOnce([{ subscription_id: 'sub_123' }]);
fullQueryMock.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map([['workspace_123', 2_000_000]]),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: true,
usage: 2_000_000,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).toHaveBeenCalledWith(
{ id: In(['item_123']) },
{ hasReachedCurrentPeriodCap: true },
);
});
it('batch-updates hasReachedCurrentPeriodCap=false in active mode when usage drops below allowance', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({
itemId: 'item_123',
hasReachedCurrentPeriodCap: true,
});
idQueryMock.mockResolvedValueOnce([{ subscription_id: 'sub_123' }]);
fullQueryMock.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map([['workspace_123', 500_000]]),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: false,
usage: 500_000,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).toHaveBeenCalledWith(
{ id: In(['item_123']) },
{ hasReachedCurrentPeriodCap: false },
);
});
it('does not update when state already matches', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({ hasReachedCurrentPeriodCap: true });
idQueryMock.mockResolvedValueOnce([{ subscription_id: 'sub_123' }]);
fullQueryMock.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map([['workspace_123', 2_000_000]]),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: true,
usage: 2_000_000,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
it('skips subscriptions without a metered item', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription();
idQueryMock.mockResolvedValueOnce([{ subscription_id: 'sub_123' }]);
fullQueryMock.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map(),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{ skipped: true as const, reason: 'no-metered-item' as const },
],
]),
);
await job.handle();
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
it('passes credit balance from billingCustomer to evaluateCapBatch', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({
stripeCustomerId: 'cus_456',
creditBalanceMicro: 300_000,
});
idQueryMock.mockResolvedValueOnce([{ subscription_id: 'sub_123' }]);
fullQueryMock.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockResolvedValue(
new Map(),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(new Map());
await job.handle();
expect(billingUsageCapService.evaluateCapBatch).toHaveBeenCalledWith(
[sub],
expect.any(Map),
new Map([['cus_456', 300_000]]),
);
});
it('skips subscriptions whose ClickHouse query failed', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: true });
const sub = buildSubscription({ hasReachedCurrentPeriodCap: true });
idQueryMock.mockResolvedValueOnce([{ subscription_id: 'sub_123' }]);
fullQueryMock.mockResolvedValueOnce([sub]);
billingUsageCapService.getBatchPeriodCreditsUsed.mockRejectedValue(
new Error('clickhouse exploded'),
);
billingUsageCapService.evaluateCapBatch.mockReturnValue(
new Map([
[
'sub_123',
{
skipped: false as const,
hasReachedCap: false,
usage: 0,
allowance: 1_000_000,
tierCap: 1_000_000,
creditBalance: 0,
},
],
]),
);
await expect(job.handle()).resolves.not.toThrow();
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
/* @license Enterprise */
import { Command, CommandRunner } from 'nest-commander';
import { enforceUsageCapCronPattern } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.cron.pattern';
import { EnforceUsageCapJob } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@Command({
name: 'cron:billing:enforce-usage-cap',
description:
'Starts the cron that re-evaluates metered-credit caps from ClickHouse usage',
})
export class EnforceUsageCapCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: EnforceUsageCapJob.name,
data: undefined,
options: {
repeat: { pattern: enforceUsageCapCronPattern },
},
});
}
}
@@ -0,0 +1,9 @@
/* @license Enterprise */
// Poll every 2 minutes.
// hasReachedCurrentPeriodCap is reset to false at the start of each billing
// period by BillingWebhookInvoiceService (on subscription_cycle invoice),
// on trial end (BillingSubscriptionService), and on plan changes
// (BillingSubscriptionUpdateService). This cron flips it true/false based
// on live ClickHouse usage within the current period.
export const enforceUsageCapCronPattern = '*/2 * * * *';
@@ -0,0 +1,251 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { enforceUsageCapCronPattern } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.cron.pattern';
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingUsageCapService } from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const BATCH_SIZE = 100;
@Injectable()
@Processor(MessageQueue.cronQueue)
export class EnforceUsageCapJob {
private readonly logger = new Logger(EnforceUsageCapJob.name);
constructor(
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
@InjectRepository(BillingSubscriptionItemEntity)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Process(EnforceUsageCapJob.name)
@SentryCronMonitor(EnforceUsageCapJob.name, enforceUsageCapCronPattern)
async handle(): Promise<void> {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
if (!this.billingUsageCapService.isClickHouseEnabled()) {
this.logger.debug(
'ClickHouse is not configured; skipping usage cap enforcement',
);
return;
}
const isEnforcementActive = this.twentyConfigService.get(
'BILLING_USAGE_CAP_CLICKHOUSE_ENABLED',
);
let evaluated = 0;
let transitioned = 0;
let errors = 0;
let offset = 0;
let batch: BillingSubscriptionEntity[];
let idRows: { subscription_id: string }[];
do {
idRows = await this.billingSubscriptionRepository
.createQueryBuilder('subscription')
.select('subscription.id')
.innerJoin('subscription.workspace', 'workspace')
.where('subscription.status IN (:...statuses)', {
statuses: [
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
],
})
.andWhere('workspace.suspendedAt IS NULL')
.orderBy('subscription.id', 'ASC')
.limit(BATCH_SIZE)
.offset(offset)
.getRawMany<{ subscription_id: string }>();
if (idRows.length === 0) {
break;
}
const ids = idRows.map((row) => row.subscription_id);
batch = await this.billingSubscriptionRepository
.createQueryBuilder('subscription')
.innerJoinAndSelect(
'subscription.billingSubscriptionItems',
'item',
'item.billingSubscriptionId = subscription.id',
)
.innerJoinAndSelect('item.billingProduct', 'product')
.leftJoinAndSelect('product.billingPrices', 'price')
.innerJoinAndSelect('subscription.billingCustomer', 'customer')
.where('subscription.id IN (:...ids)', { ids })
.orderBy('subscription.id', 'ASC')
.getMany();
if (batch.length === 0) {
break;
}
const periodGroups = this.groupByPeriod(batch);
const usageByWorkspace = new Map<string, number>();
const failedWorkspaceIds = new Set<string>();
for (const [, group] of periodGroups) {
try {
const workspaceIds = group.map((s) => s.workspaceId);
const batchUsage =
await this.billingUsageCapService.getBatchPeriodCreditsUsed(
workspaceIds,
group[0].currentPeriodStart,
group[0].currentPeriodEnd,
);
for (const [id, usage] of batchUsage) {
usageByWorkspace.set(id, usage);
}
} catch (error) {
for (const sub of group) {
failedWorkspaceIds.add(sub.workspaceId);
}
errors += group.length;
this.logger.error(
`Failed to fetch batch usage from ClickHouse for ${group.length} subscriptions`,
error instanceof Error ? error.stack : String(error),
);
}
}
const creditBalanceByCustomer = new Map<string, number>();
for (const subscription of batch) {
if (subscription.billingCustomer) {
creditBalanceByCustomer.set(
subscription.stripeCustomerId,
subscription.billingCustomer.creditBalanceMicro,
);
}
}
const evaluations = this.billingUsageCapService.evaluateCapBatch(
batch,
usageByWorkspace,
creditBalanceByCustomer,
);
const idsToCapTrue: string[] = [];
const idsToCapFalse: string[] = [];
for (const subscription of batch) {
if (failedWorkspaceIds.has(subscription.workspaceId)) {
continue;
}
const evaluation = evaluations.get(subscription.id);
if (!evaluation || evaluation.skipped) {
continue;
}
evaluated += 1;
const meteredItem = subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct?.metadata?.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!meteredItem) {
continue;
}
const shouldBeCapped = evaluation.hasReachedCap;
if (meteredItem.hasReachedCurrentPeriodCap === shouldBeCapped) {
continue;
}
if (!isEnforcementActive) {
this.logger.log(
`[shadow] would set hasReachedCurrentPeriodCap=${shouldBeCapped} ` +
`for subscription=${subscription.id} workspace=${subscription.workspaceId} ` +
`usage=${evaluation.usage} allowance=${evaluation.allowance} ` +
`tierCap=${evaluation.tierCap} creditBalance=${evaluation.creditBalance}`,
);
continue;
}
if (shouldBeCapped) {
idsToCapTrue.push(meteredItem.id);
} else {
idsToCapFalse.push(meteredItem.id);
}
this.logger.log(
`Set hasReachedCurrentPeriodCap=${shouldBeCapped} ` +
`for subscription=${subscription.id} workspace=${subscription.workspaceId} ` +
`usage=${evaluation.usage} allowance=${evaluation.allowance} ` +
`tierCap=${evaluation.tierCap} creditBalance=${evaluation.creditBalance}`,
);
}
if (idsToCapTrue.length > 0) {
await this.billingSubscriptionItemRepository.update(
{ id: In(idsToCapTrue) },
{ hasReachedCurrentPeriodCap: true },
);
transitioned += idsToCapTrue.length;
}
if (idsToCapFalse.length > 0) {
await this.billingSubscriptionItemRepository.update(
{ id: In(idsToCapFalse) },
{ hasReachedCurrentPeriodCap: false },
);
transitioned += idsToCapFalse.length;
}
offset += idRows.length;
} while (idRows.length === BATCH_SIZE);
this.logger.log(
`Usage cap enforcement run complete: evaluated=${evaluated} ` +
`transitioned=${transitioned} errors=${errors} ` +
`mode=${isEnforcementActive ? 'active' : 'shadow'}`,
);
}
private groupByPeriod(
subscriptions: BillingSubscriptionEntity[],
): Map<string, BillingSubscriptionEntity[]> {
const groups = new Map<string, BillingSubscriptionEntity[]>();
for (const subscription of subscriptions) {
const key = `${subscription.currentPeriodStart.toISOString()}|${subscription.currentPeriodEnd.toISOString()}`;
const group = groups.get(key);
if (group) {
group.push(subscription);
} else {
groups.set(key, [subscription]);
}
}
return groups;
}
}
@@ -41,6 +41,18 @@ export class BillingCustomerEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, unique: true })
stripeCustomerId: string;
@Column({
type: 'bigint',
nullable: false,
default: 0,
transformer: {
to: (value: number) => value,
from: (value: string | number | null) =>
typeof value === 'string' ? Number(value) : (value ?? 0),
},
})
creditBalanceMicro: number;
@OneToMany(
() => BillingSubscriptionEntity,
(billingSubscription) => billingSubscription.billingCustomer,
@@ -1,9 +1,11 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type Stripe from 'stripe';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
@@ -12,6 +14,7 @@ describe('BillingCreditRolloverService', () => {
let service: BillingCreditRolloverService;
let stripeCreditGrantService: jest.Mocked<StripeCreditGrantService>;
let stripeBillingMeterEventService: jest.Mocked<StripeBillingMeterEventService>;
let billingCustomerRepository: jest.Mocked<{ update: jest.Mock }>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -23,6 +26,7 @@ describe('BillingCreditRolloverService', () => {
createCreditGrant: jest.fn(),
listCreditGrants: jest.fn().mockResolvedValue([]),
voidCreditGrant: jest.fn(),
getCustomerCreditBalance: jest.fn().mockResolvedValue(0),
},
},
{
@@ -31,6 +35,12 @@ describe('BillingCreditRolloverService', () => {
sumMeterEvents: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingCustomerEntity),
useValue: {
update: jest.fn(),
},
},
],
}).compile();
@@ -39,6 +49,9 @@ describe('BillingCreditRolloverService', () => {
);
stripeCreditGrantService = module.get(StripeCreditGrantService);
stripeBillingMeterEventService = module.get(StripeBillingMeterEventService);
billingCustomerRepository = module.get(
getRepositoryToken(BillingCustomerEntity),
);
});
describe('processRolloverOnPeriodTransition', () => {
@@ -139,5 +152,34 @@ describe('BillingCreditRolloverService', () => {
}),
);
});
it('should persist credit balance to Postgres after rollover', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(300);
stripeCreditGrantService.getCustomerCreditBalance.mockResolvedValue(
700_000,
);
await service.processRolloverOnPeriodTransition(baseParams);
expect(
stripeCreditGrantService.getCustomerCreditBalance,
).toHaveBeenCalledWith('cus_123', 10);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 700_000 },
);
});
it('should persist credit balance even when no grant is created', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(1000);
stripeCreditGrantService.getCustomerCreditBalance.mockResolvedValue(0);
await service.processRolloverOnPeriodTransition(baseParams);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 0 },
);
});
});
});
@@ -0,0 +1,449 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import {
type BillingCapEvaluation,
BillingUsageCapService,
} from 'src/engine/core-modules/billing/services/billing-usage-cap.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('BillingUsageCapService', () => {
let service: BillingUsageCapService;
let clickHouseService: jest.Mocked<ClickHouseService>;
let meteredCreditService: jest.Mocked<MeteredCreditService>;
let twentyConfigService: jest.Mocked<TwentyConfigService>;
const buildSubscription = (
overrides: Partial<BillingSubscriptionEntity> = {},
): BillingSubscriptionEntity =>
({
id: 'sub_123',
workspaceId: 'workspace_123',
stripeCustomerId: 'cus_123',
currentPeriodStart: new Date('2026-04-01T00:00:00Z'),
currentPeriodEnd: new Date('2026-05-01T00:00:00Z'),
...overrides,
}) as BillingSubscriptionEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingUsageCapService,
{
provide: ClickHouseService,
useValue: {
select: jest.fn(),
},
},
{
provide: MeteredCreditService,
useValue: {
extractMeteredPricingInfoFromSubscription: jest.fn(),
getCreditBalance: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<BillingUsageCapService>(BillingUsageCapService);
clickHouseService = module.get(ClickHouseService);
meteredCreditService = module.get(MeteredCreditService);
twentyConfigService = module.get(TwentyConfigService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('isClickHouseEnabled', () => {
it('returns true when CLICKHOUSE_URL is configured', () => {
twentyConfigService.get.mockReturnValue('http://clickhouse:8123');
expect(service.isClickHouseEnabled()).toBe(true);
});
it('returns false when CLICKHOUSE_URL is empty', () => {
twentyConfigService.get.mockReturnValue('');
expect(service.isClickHouseEnabled()).toBe(false);
});
});
describe('getCurrentPeriodCreditsUsed', () => {
beforeEach(() => {
twentyConfigService.get.mockReturnValue('http://clickhouse:8123');
});
it('returns 0 when ClickHouse is disabled', async () => {
twentyConfigService.get.mockReturnValue('');
const result = await service.getCurrentPeriodCreditsUsed(
'workspace_123',
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result).toBe(0);
expect(clickHouseService.select).not.toHaveBeenCalled();
});
it('sums creditsUsedMicro for the workspace in the given period', async () => {
clickHouseService.select.mockResolvedValue([{ total: 12345 }]);
const result = await service.getCurrentPeriodCreditsUsed(
'workspace_123',
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result).toBe(12345);
expect(clickHouseService.select).toHaveBeenCalledTimes(1);
const [query, params] = clickHouseService.select.mock.calls[0];
expect(query).toContain('sum(creditsUsedMicro)');
expect(query).toContain('FROM usageEvent');
expect(params).toMatchObject({
workspaceId: 'workspace_123',
});
expect(query).not.toContain('operationType');
});
it('coerces string totals returned by ClickHouse to a number', async () => {
clickHouseService.select.mockResolvedValue([{ total: '9876543210' }]);
const result = await service.getCurrentPeriodCreditsUsed(
'workspace_123',
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result).toBe(9876543210);
});
it('returns 0 when no rows are returned', async () => {
clickHouseService.select.mockResolvedValue([]);
const result = await service.getCurrentPeriodCreditsUsed(
'workspace_123',
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result).toBe(0);
});
it('returns 0 when total is null', async () => {
clickHouseService.select.mockResolvedValue([{ total: null }]);
const result = await service.getCurrentPeriodCreditsUsed(
'workspace_123',
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result).toBe(0);
});
});
describe('getBatchPeriodCreditsUsed', () => {
beforeEach(() => {
twentyConfigService.get.mockReturnValue('http://clickhouse:8123');
});
it('returns empty map when ClickHouse is disabled', async () => {
twentyConfigService.get.mockReturnValue('');
const result = await service.getBatchPeriodCreditsUsed(
['ws_1', 'ws_2'],
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result.size).toBe(0);
expect(clickHouseService.select).not.toHaveBeenCalled();
});
it('returns empty map when workspaceIds is empty', async () => {
const result = await service.getBatchPeriodCreditsUsed(
[],
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result.size).toBe(0);
expect(clickHouseService.select).not.toHaveBeenCalled();
});
it('returns usage grouped by workspaceId', async () => {
clickHouseService.select.mockResolvedValue([
{ workspaceId: 'ws_1', total: 500_000 },
{ workspaceId: 'ws_2', total: 1_200_000 },
]);
const result = await service.getBatchPeriodCreditsUsed(
['ws_1', 'ws_2', 'ws_3'],
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result.get('ws_1')).toBe(500_000);
expect(result.get('ws_2')).toBe(1_200_000);
expect(result.has('ws_3')).toBe(false);
const [query, params] = clickHouseService.select.mock.calls[0];
expect(query).toContain('GROUP BY workspaceId');
expect(query).toContain('IN {workspaceIds:Array(String)}');
expect(params).toMatchObject({
workspaceIds: ['ws_1', 'ws_2', 'ws_3'],
});
});
it('coerces string totals from ClickHouse', async () => {
clickHouseService.select.mockResolvedValue([
{ workspaceId: 'ws_1', total: '9876543210' },
]);
const result = await service.getBatchPeriodCreditsUsed(
['ws_1'],
new Date('2026-04-01T00:00:00Z'),
new Date('2026-05-01T00:00:00Z'),
);
expect(result.get('ws_1')).toBe(9876543210);
});
});
describe('evaluateCap', () => {
beforeEach(() => {
twentyConfigService.get.mockReturnValue('http://clickhouse:8123');
});
it('returns skipped when ClickHouse is disabled', async () => {
twentyConfigService.get.mockReturnValue('');
const result = await service.evaluateCap(buildSubscription());
expect(result).toEqual<BillingCapEvaluation>({
skipped: true,
reason: 'clickhouse-disabled',
});
expect(
meteredCreditService.extractMeteredPricingInfoFromSubscription,
).not.toHaveBeenCalled();
});
it('returns skipped when subscription has no metered item', async () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
null,
);
const result = await service.evaluateCap(buildSubscription());
expect(result).toEqual<BillingCapEvaluation>({
skipped: true,
reason: 'no-metered-item',
});
expect(clickHouseService.select).not.toHaveBeenCalled();
});
it('reports hasReachedCap=false when usage is below tierCap + creditBalance', async () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
meteredCreditService.getCreditBalance.mockResolvedValue(250_000);
clickHouseService.select.mockResolvedValue([{ total: 800_000 }]);
const result = await service.evaluateCap(buildSubscription());
expect(result).toEqual<BillingCapEvaluation>({
skipped: false,
hasReachedCap: false,
usage: 800_000,
allowance: 1_250_000,
tierCap: 1_000_000,
creditBalance: 250_000,
});
});
it('reports hasReachedCap=true when usage meets tierCap + creditBalance', async () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
meteredCreditService.getCreditBalance.mockResolvedValue(0);
clickHouseService.select.mockResolvedValue([{ total: 1_000_000 }]);
const result = await service.evaluateCap(buildSubscription());
expect(result).toMatchObject({
skipped: false,
hasReachedCap: true,
usage: 1_000_000,
allowance: 1_000_000,
});
});
it('reports hasReachedCap=true when usage exceeds tierCap + creditBalance', async () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
meteredCreditService.getCreditBalance.mockResolvedValue(100_000);
clickHouseService.select.mockResolvedValue([{ total: 5_000_000 }]);
const result = await service.evaluateCap(buildSubscription());
expect(result).toMatchObject({
skipped: false,
hasReachedCap: true,
usage: 5_000_000,
allowance: 1_100_000,
});
});
it('re-reads pricing on each call so that tier changes apply immediately', async () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription
.mockReturnValueOnce({ tierCap: 2_000_000, unitPriceCents: 10 })
.mockReturnValueOnce({ tierCap: 500_000, unitPriceCents: 10 });
meteredCreditService.getCreditBalance.mockResolvedValue(0);
clickHouseService.select.mockResolvedValue([{ total: 1_000_000 }]);
const first = await service.evaluateCap(buildSubscription());
const second = await service.evaluateCap(buildSubscription());
expect(first).toMatchObject({
skipped: false,
hasReachedCap: false,
allowance: 2_000_000,
});
expect(second).toMatchObject({
skipped: false,
hasReachedCap: true,
allowance: 500_000,
});
});
});
describe('evaluateCapBatch', () => {
it('returns evaluations keyed by subscription id', () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
const sub1 = buildSubscription({
id: 'sub_1',
workspaceId: 'ws_1',
stripeCustomerId: 'cus_1',
});
const sub2 = buildSubscription({
id: 'sub_2',
workspaceId: 'ws_2',
stripeCustomerId: 'cus_2',
});
const usageByWorkspace = new Map([
['ws_1', 500_000],
['ws_2', 1_500_000],
]);
const creditBalanceByCustomer = new Map([
['cus_1', 0],
['cus_2', 200_000],
]);
const results = service.evaluateCapBatch(
[sub1, sub2],
usageByWorkspace,
creditBalanceByCustomer,
);
expect(results.get('sub_1')).toMatchObject({
skipped: false,
hasReachedCap: false,
usage: 500_000,
allowance: 1_000_000,
});
expect(results.get('sub_2')).toMatchObject({
skipped: false,
hasReachedCap: true,
usage: 1_500_000,
allowance: 1_200_000,
});
});
it('defaults usage to 0 for workspaces not in the map', () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
const sub = buildSubscription({
id: 'sub_1',
workspaceId: 'ws_unknown',
stripeCustomerId: 'cus_1',
});
const results = service.evaluateCapBatch(
[sub],
new Map(),
new Map([['cus_1', 0]]),
);
expect(results.get('sub_1')).toMatchObject({
skipped: false,
hasReachedCap: false,
usage: 0,
allowance: 1_000_000,
});
});
it('defaults credit balance to 0 for unknown customers', () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
{ tierCap: 1_000_000, unitPriceCents: 10 },
);
const sub = buildSubscription({
id: 'sub_1',
workspaceId: 'ws_1',
stripeCustomerId: 'cus_unknown',
});
const results = service.evaluateCapBatch(
[sub],
new Map([['ws_1', 500_000]]),
new Map(),
);
expect(results.get('sub_1')).toMatchObject({
skipped: false,
hasReachedCap: false,
usage: 500_000,
creditBalance: 0,
allowance: 1_000_000,
});
});
it('returns skipped for subscriptions without metered pricing', () => {
meteredCreditService.extractMeteredPricingInfoFromSubscription.mockReturnValue(
null,
);
const sub = buildSubscription({ id: 'sub_1' });
const results = service.evaluateCapBatch([sub], new Map(), new Map());
expect(results.get('sub_1')).toEqual({
skipped: true,
reason: 'no-metered-item',
});
});
});
});
@@ -1,7 +1,11 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
@@ -10,6 +14,8 @@ export class BillingCreditRolloverService {
constructor(
private readonly stripeCreditGrantService: StripeCreditGrantService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
) {}
async processRolloverOnPeriodTransition({
@@ -46,6 +52,8 @@ export class BillingCreditRolloverService {
const unusedCredits = Math.max(0, tierQuantity - usedCredits);
if (unusedCredits <= 0) {
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
return;
}
@@ -63,6 +71,24 @@ export class BillingCreditRolloverService {
subscriptionId,
},
});
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
}
private async refreshCreditBalance(
stripeCustomerId: string,
unitPriceCents: number,
): Promise<void> {
const creditBalanceMicro =
await this.stripeCreditGrantService.getCustomerCreditBalance(
stripeCustomerId,
unitPriceCents,
);
await this.billingCustomerRepository.update(
{ stripeCustomerId },
{ creditBalanceMicro },
);
}
private async voidExistingRolloverGrants(
@@ -0,0 +1,189 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export type BillingCapEvaluation =
| {
skipped: false;
hasReachedCap: boolean;
usage: number;
allowance: number;
tierCap: number;
creditBalance: number;
}
| {
skipped: true;
reason: 'no-metered-item' | 'clickhouse-disabled';
};
type UsageSumRow = {
total: string | number | null;
};
type BatchUsageSumRow = {
workspaceId: string;
total: string | number | null;
};
@Injectable()
export class BillingUsageCapService {
constructor(
private readonly clickHouseService: ClickHouseService,
private readonly meteredCreditService: MeteredCreditService,
private readonly twentyConfigService: TwentyConfigService,
) {}
isClickHouseEnabled(): boolean {
return Boolean(this.twentyConfigService.get('CLICKHOUSE_URL'));
}
async getCurrentPeriodCreditsUsed(
workspaceId: string,
periodStart: Date,
periodEnd: Date,
): Promise<number> {
if (!this.isClickHouseEnabled()) {
return 0;
}
const query = `
SELECT sum(creditsUsedMicro) AS total
FROM usageEvent
WHERE workspaceId = {workspaceId:String}
AND timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
`;
const rows = await this.clickHouseService.select<UsageSumRow>(query, {
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
});
const rawTotal = rows[0]?.total ?? 0;
const total = typeof rawTotal === 'string' ? Number(rawTotal) : rawTotal;
return Number.isFinite(total) ? total : 0;
}
async getBatchPeriodCreditsUsed(
workspaceIds: string[],
periodStart: Date,
periodEnd: Date,
): Promise<Map<string, number>> {
const result = new Map<string, number>();
if (!this.isClickHouseEnabled() || workspaceIds.length === 0) {
return result;
}
const query = `
SELECT workspaceId, sum(creditsUsedMicro) AS total
FROM usageEvent
WHERE workspaceId IN {workspaceIds:Array(String)}
AND timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
GROUP BY workspaceId
`;
const rows = await this.clickHouseService.select<BatchUsageSumRow>(query, {
workspaceIds,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
});
for (const row of rows) {
const rawTotal = row.total ?? 0;
const total = typeof rawTotal === 'string' ? Number(rawTotal) : rawTotal;
result.set(row.workspaceId, Number.isFinite(total) ? total : 0);
}
return result;
}
async evaluateCap(
subscription: BillingSubscriptionEntity,
): Promise<BillingCapEvaluation> {
if (!this.isClickHouseEnabled()) {
return { skipped: true, reason: 'clickhouse-disabled' };
}
const meteredPricingInfo =
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
subscription,
);
if (!meteredPricingInfo) {
return { skipped: true, reason: 'no-metered-item' };
}
const [creditBalance, usage] = await Promise.all([
this.meteredCreditService.getCreditBalance(
subscription.stripeCustomerId,
meteredPricingInfo.unitPriceCents,
),
this.getCurrentPeriodCreditsUsed(
subscription.workspaceId,
subscription.currentPeriodStart,
subscription.currentPeriodEnd,
),
]);
const allowance = meteredPricingInfo.tierCap + creditBalance;
return {
skipped: false,
hasReachedCap: usage >= allowance,
usage,
allowance,
tierCap: meteredPricingInfo.tierCap,
creditBalance,
};
}
evaluateCapBatch(
subscriptions: BillingSubscriptionEntity[],
usageByWorkspace: Map<string, number>,
creditBalanceByCustomer: Map<string, number>,
): Map<string, BillingCapEvaluation> {
const results = new Map<string, BillingCapEvaluation>();
for (const subscription of subscriptions) {
const meteredPricingInfo =
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
subscription,
);
if (!meteredPricingInfo) {
results.set(subscription.id, {
skipped: true,
reason: 'no-metered-item',
});
continue;
}
const usage = usageByWorkspace.get(subscription.workspaceId) ?? 0;
const creditBalance =
creditBalanceByCustomer.get(subscription.stripeCustomerId) ?? 0;
const allowance = meteredPricingInfo.tierCap + creditBalance;
results.set(subscription.id, {
skipped: false,
hasReachedCap: usage >= allowance,
usage,
allowance,
tierCap: meteredPricingInfo.tierCap,
creditBalance,
});
}
return results;
}
}
@@ -54,9 +54,15 @@ export class MeteredCreditService {
return null;
}
const meteredItem = subscription.billingSubscriptionItems.find(
return this.extractMeteredPricingInfoFromSubscription(subscription);
}
extractMeteredPricingInfoFromSubscription(
subscription: BillingSubscriptionEntity,
): MeteredPricingInfo | null {
const meteredItem = subscription.billingSubscriptionItems?.find(
(item) =>
item.billingProduct.metadata.productKey ===
item.billingProduct?.metadata?.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
@@ -64,7 +70,7 @@ export class MeteredCreditService {
return null;
}
const matchingPrice = meteredItem.billingProduct.billingPrices.find(
const matchingPrice = meteredItem.billingProduct.billingPrices?.find(
(price) => price.stripePriceId === meteredItem.stripePriceId,
);
@@ -6,6 +6,8 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AuditJobModule } from 'src/engine/core-modules/audit/jobs/audit-job.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { EnforceUsageCapJob } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.job';
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
@@ -42,7 +44,11 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity, BillingSubscriptionEntity]),
TypeOrmModule.forFeature([
WorkspaceEntity,
BillingSubscriptionEntity,
BillingSubscriptionItemEntity,
]),
ObjectMetadataModule,
TypeORMModule,
UserModule,
@@ -75,6 +81,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
CleanSuspendedWorkspacesJob,
CleanOnboardingWorkspacesJob,
EmailSenderJob,
EnforceUsageCapJob,
UpdateSubscriptionQuantityJob,
HandleWorkspaceMemberDeletedJob,
CleanWorkspaceDeletionWarningUserVarsJob,
@@ -815,6 +815,15 @@ export class ConfigVariables {
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_WEBHOOK_SECRET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description:
'Use the ClickHouse-backed poller (instead of Stripe billing alerts) as the source of truth for metered-credit cap enforcement',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
BILLING_USAGE_CAP_CLICKHOUSE_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Url for the frontend application',