chore(billing) - remove feature flag (#20531)

- remove feature flag
- remove old enforce cap usage logic
This commit is contained in:
Etienne
2026-05-13 18:52:29 +02:00
committed by GitHub
parent 695a374efd
commit fcd2d586ee
69 changed files with 729 additions and 4371 deletions
@@ -16,7 +16,6 @@ import { type Response } from 'express';
import Stripe from 'stripe';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCreditGrantService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-credit-grant.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -51,7 +50,6 @@ export class BillingWebhookController {
private readonly billingWebhookInvoiceService: BillingWebhookInvoiceService,
private readonly billingWebhookCustomerService: BillingWebhookCustomerService,
private readonly billingWebhookSubscriptionScheduleService: BillingWebhookSubscriptionScheduleService,
private readonly billingWebhookCreditGrantService: BillingWebhookCreditGrantService,
) {}
@Post(['webhooks/stripe'])
@@ -154,25 +152,6 @@ export class BillingWebhookController {
);
}
case BillingWebhookEvent.CREDIT_GRANT_CREATED:
case BillingWebhookEvent.CREDIT_GRANT_UPDATED: {
const customer = event.data.object.customer;
// customer can be string ID, Customer object, or DeletedCustomer object
const stripeCustomerId =
typeof customer === 'string' ? customer : customer?.id;
if (!stripeCustomerId) {
throw new BillingException(
'Customer ID is required for credit grant events',
BillingExceptionCode.BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND,
);
}
return await this.billingWebhookCreditGrantService.processStripeEvent(
stripeCustomerId,
);
}
default:
return {};
}
@@ -4,7 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { BillingWebhookController } from 'src/engine/core-modules/billing-webhook/billing-webhook.controller';
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
import { BillingWebhookCreditGrantService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-credit-grant.service';
import { BillingWebhookCustomerService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service';
import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service';
@@ -65,7 +64,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingWebhookSubscriptionService,
BillingWebhookSubscriptionScheduleService,
BillingWebhookEntitlementService,
BillingWebhookCreditGrantService,
],
})
export class BillingWebhookModule {}
@@ -1,52 +0,0 @@
/* @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';
@Injectable()
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> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscription({
stripeCustomerId,
});
if (!isDefined(subscription)) {
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,
);
}
}
@@ -1,8 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { addMonths, addYears } from 'date-fns';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type Repository } from 'typeorm';
@@ -19,13 +17,11 @@ import {
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
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 { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const SUBSCRIPTION_CYCLE_BILLING_REASON = 'subscription_cycle';
@@ -43,9 +39,8 @@ export class BillingWebhookInvoiceService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingCreditRolloverService: BillingCreditRolloverService,
private readonly meteredCreditService: MeteredCreditService,
private readonly resourceCreditService: ResourceCreditService,
private readonly stripeInvoiceService: StripeInvoiceService,
private readonly featureFlagService: FeatureFlagService,
private readonly auditService: AuditService,
) {}
@@ -115,85 +110,28 @@ export class BillingWebhookInvoiceService {
Math.abs(periodStart - trialEnd) <= TRIAL_END_TOLERANCE_SECONDS;
if (periodStart && !isFirstPeriodAfterTrial) {
await this.processRollover(
subscription,
new Date(periodStart * 1000),
new Date(periodEnd * 1000),
);
}
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
subscription.workspaceId,
);
if (!isV2) {
// Pass the new period start (which is the invoiced period's end) for alert threshold calculation
await this.meteredCreditService.recreateBillingAlertForSubscription(
subscription,
new Date(periodEnd * 1000),
);
await this.processRollover(subscription, new Date(periodStart * 1000));
}
}
private async processRollover(
subscription: BillingSubscriptionEntity,
invoicedPeriodStart: Date,
invoicedPeriodEnd: Date,
): Promise<void> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
subscription.workspaceId,
);
if (isV2) {
const v2Params =
await this.meteredCreditService.getResourceCreditRolloverParameters(
subscription.id,
);
if (!isDefined(v2Params)) {
return;
}
await this.billingCreditRolloverService.processRolloverOnPeriodTransitionV2(
{
workspaceId: subscription.workspaceId,
stripeCustomerId: subscription.stripeCustomerId,
tierQuantity: v2Params.tierQuantity,
previousPeriodStart: invoicedPeriodStart,
},
);
return;
}
const rolloverParams =
await this.meteredCreditService.getMeteredRolloverParameters(
const params =
await this.resourceCreditService.getResourceCreditRolloverParameters(
subscription.id,
);
if (!isDefined(rolloverParams)) {
if (!isDefined(params)) {
return;
}
// The invoice covers the period that just ended (invoicedPeriodStart to invoicedPeriodEnd)
// We need to calculate unused credits from this period and roll them over
// Credits should expire at the end of the NEXT period
const nextPeriodEnd = this.calculateNextPeriodEnd(
invoicedPeriodEnd,
subscription.interval,
);
await this.billingCreditRolloverService.processRolloverOnPeriodTransition({
workspaceId: subscription.workspaceId,
stripeCustomerId: subscription.stripeCustomerId,
subscriptionId: subscription.id,
stripeMeterId: rolloverParams.stripeMeterId,
tierQuantity: params.tierQuantity,
previousPeriodStart: invoicedPeriodStart,
previousPeriodEnd: invoicedPeriodEnd,
newPeriodEnd: nextPeriodEnd,
tierQuantity: rolloverParams.tierQuantity,
unitPriceCents: rolloverParams.unitPriceCents,
});
}
@@ -284,15 +222,4 @@ export class BillingWebhookInvoiceService {
suspendedAt: new Date(),
});
}
private calculateNextPeriodEnd(
periodEnd: Date,
interval: SubscriptionInterval,
): Date {
if (interval === SubscriptionInterval.Year) {
return addYears(periodEnd, 1);
}
return addMonths(periodEnd, 1);
}
}
@@ -10,7 +10,6 @@ import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolve
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';
@@ -19,7 +18,6 @@ import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/b
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 { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
import { BillingUsageEventListener } from 'src/engine/core-modules/billing/listeners/billing-usage-event.listener';
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener';
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
@@ -33,7 +31,7 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
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';
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
import { WorkspaceBillingSubscriptionCacheService } from 'src/engine/core-modules/billing/services/workspace-billing-subscription-cache.service';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
@@ -82,7 +80,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingResolver,
BillingPlanService,
BillingWorkspaceMemberListener,
BillingUsageEventListener,
BillingService,
BillingRestApiExceptionFilter,
BillingSyncCustomerDataCommand,
@@ -92,9 +89,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingUsageCapService,
BillingPriceService,
BillingCreditRolloverService,
MeteredCreditService,
ResourceCreditService,
BillingGaugeService,
EnforceUsageCapCronCommand,
WorkspaceBillingSubscriptionCacheService,
],
exports: [
@@ -107,8 +103,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingUsageService,
BillingUsageCapService,
BillingCreditRolloverService,
MeteredCreditService,
EnforceUsageCapCronCommand,
ResourceCreditService,
],
})
export class BillingModule {}
@@ -10,7 +10,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
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 { BillingResourceCreditUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-resource-credit-usage.dto';
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
@@ -25,7 +25,6 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
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 {
@@ -48,8 +47,6 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { FeatureFlagKey } from 'twenty-shared/types';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(
@@ -65,7 +62,6 @@ export class BillingResolver {
private readonly billingService: BillingService,
private readonly billingUsageService: BillingUsageService,
private readonly permissionsService: PermissionsService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Query(() => BillingSessionDTO)
@@ -237,27 +233,15 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async setMeteredSubscriptionPrice(
async setResourceCreditSubscriptionPrice(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
) {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
await this.billingSubscriptionUpdateService.changeResourceCreditPrice(
workspace.id,
priceId,
);
if (isV2) {
await this.billingSubscriptionUpdateService.changeResourceCreditPrice(
workspace.id,
priceId,
);
} else {
await this.billingSubscriptionUpdateService.changeMeteredPrice(
workspace.id,
priceId,
);
}
return {
billingSubscriptions:
await this.billingSubscriptionService.getBillingSubscriptions(
@@ -310,23 +294,16 @@ export class BillingResolver {
};
}
@Query(() => [BillingMeteredProductUsageDTO])
@Query(() => [BillingResourceCreditUsageDTO])
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
//TODO: To rename to getResourceCreditProductsUsage
async getMeteredProductsUsage(
async getResourceCreditUsage(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspace.id,
);
const usageData = isV2
? await this.billingUsageService.getResourceCreditProductUsage(workspace)
: await this.billingUsageService.getMeteredProductsUsage(workspace);
): Promise<BillingResourceCreditUsageDTO[]> {
const usageData =
await this.billingUsageService.getResourceCreditProductUsage(workspace);
return usageData.map((item) => ({
...item,
@@ -343,8 +320,10 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async cancelSwitchMeteredPrice(@AuthWorkspace() workspace: WorkspaceEntity) {
await this.billingSubscriptionUpdateService.cancelSwitchMeteredPrice(
async cancelSwitchResourceCreditPrice(
@AuthWorkspace() workspace: WorkspaceEntity,
) {
await this.billingSubscriptionUpdateService.cancelSwitchResourceCreditPrice(
workspace,
);
@@ -1,377 +0,0 @@
/* @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 { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.entity';
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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const METERED_STRIPE_PRODUCT_ID = 'prod_metered';
const METERED_STRIPE_PRICE_ID = 'price_metered';
describe('EnforceUsageCapJob', () => {
let job: EnforceUsageCapJob;
let billingSubscriptionFindMock: jest.Mock;
let billingSubscriptionItemRepository: jest.Mocked<{
update: jest.Mock;
}>;
let billingUsageCapService: jest.Mocked<
Pick<
BillingUsageCapService,
| 'isClickHouseEnabled'
| 'getBatchPeriodCreditsUsed'
| 'evaluateCapBatch'
| 'evaluateCapBatchV2'
>
>;
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,
stripeProductId: METERED_STRIPE_PRODUCT_ID,
stripePriceId: METERED_STRIPE_PRICE_ID,
},
],
}) as unknown as BillingSubscriptionEntity;
beforeEach(async () => {
billingSubscriptionFindMock = jest.fn().mockResolvedValue([]);
const module: TestingModule = await Test.createTestingModule({
providers: [
EnforceUsageCapJob,
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: { find: billingSubscriptionFindMock },
},
{
provide: getRepositoryToken(BillingSubscriptionItemEntity),
useValue: { update: jest.fn() },
},
{
provide: getRepositoryToken(BillingProductEntity),
useValue: {
find: jest.fn().mockResolvedValue([
{
stripeProductId: METERED_STRIPE_PRODUCT_ID,
metadata: {
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
},
billingPrices: [],
},
]),
},
},
{
provide: BillingUsageCapService,
useValue: {
isClickHouseEnabled: jest.fn().mockReturnValue(true),
getBatchPeriodCreditsUsed: jest.fn().mockResolvedValue(new Map()),
evaluateCapBatch: jest.fn().mockReturnValue(new Map()),
evaluateCapBatchV2: jest.fn().mockReturnValue(new Map()),
},
},
{
provide: TwentyConfigService,
useValue: { get: jest.fn() },
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
],
}).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(billingSubscriptionFindMock).not.toHaveBeenCalled();
});
it('no-ops when ClickHouse is not configured', async () => {
mockConfig();
billingUsageCapService.isClickHouseEnabled.mockReturnValue(false);
await job.handle();
expect(billingSubscriptionFindMock).not.toHaveBeenCalled();
});
it('skips transitions in shadow mode (flag off)', async () => {
mockConfig({ BILLING_USAGE_CAP_CLICKHOUSE_ENABLED: false });
const sub = buildSubscription({ hasReachedCurrentPeriodCap: false });
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.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,
});
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.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,
});
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.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 });
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.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();
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.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,
});
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.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 });
billingSubscriptionFindMock
.mockResolvedValueOnce([{ id: 'sub_123' }])
.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();
});
});
@@ -1,33 +0,0 @@
/* @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 },
},
});
}
}
@@ -1,9 +0,0 @@
/* @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 * * * *';
@@ -1,296 +0,0 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { enforceUsageCapCronPattern } from 'src/engine/core-modules/billing/crons/enforce-usage-cap.cron.pattern';
import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.entity';
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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
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>,
@InjectRepository(BillingProductEntity)
private readonly billingProductRepository: Repository<BillingProductEntity>,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
) {}
@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;
const allProducts = await this.billingProductRepository.find({
relations: { billingPrices: true },
});
const productByStripeProductId = new Map(
allProducts.map((product) => [product.stripeProductId, product]),
);
let batch: BillingSubscriptionEntity[];
let idRows: Pick<BillingSubscriptionEntity, 'id'>[];
do {
idRows = await this.billingSubscriptionRepository.find({
select: { id: true },
relations: {
workspace: true,
},
where: {
status: In([
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
]),
workspace: { suspendedAt: IsNull() },
},
order: { id: 'ASC' },
take: BATCH_SIZE,
skip: offset,
});
if (idRows.length === 0) {
break;
}
const ids = idRows.map((row) => row.id);
batch = await this.billingSubscriptionRepository.find({
where: { id: In(ids) },
relations: {
billingCustomer: true,
billingSubscriptionItems: true,
},
});
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,
);
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,
);
}
for (const item of subscription.billingSubscriptionItems ?? []) {
const product = productByStripeProductId.get(item.stripeProductId);
if (product) {
item.billingProduct = product;
}
}
}
// Collect V2 workspace IDs in this batch (Redis-cached, so ~0 cost per call)
const v2WorkspaceIds = new Set<string>();
for (const subscription of batch) {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
subscription.workspaceId,
);
if (isV2) {
v2WorkspaceIds.add(subscription.workspaceId);
}
}
const v2Batch = batch.filter((s) => v2WorkspaceIds.has(s.workspaceId));
const v1Batch = batch.filter((s) => !v2WorkspaceIds.has(s.workspaceId));
const v1Evaluations = this.billingUsageCapService.evaluateCapBatch(
v1Batch,
usageByWorkspace,
creditBalanceByCustomer,
);
const v2Evaluations = this.billingUsageCapService.evaluateCapBatchV2(
v2Batch,
usageByWorkspace,
creditBalanceByCustomer,
);
const evaluations = new Map([...v1Evaluations, ...v2Evaluations]);
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;
// V2: find item by RESOURCE_CREDIT; V1: find by WORKFLOW_NODE_EXECUTION
const targetProductKey = v2WorkspaceIds.has(subscription.workspaceId)
? BillingProductKey.RESOURCE_CREDIT
: BillingProductKey.WORKFLOW_NODE_EXECUTION;
const meteredItem = subscription.billingSubscriptionItems.find(
(item) =>
productByStripeProductId.get(item.stripeProductId)?.metadata
?.productKey === targetProductKey,
);
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 = `${formatDateForClickHouse(subscription.currentPeriodStart)}`;
const group = groups.get(key);
if (group) {
group.push(subscription);
} else {
groups.set(key, [subscription]);
}
}
return groups;
}
}
@@ -3,12 +3,11 @@ import { Field, InterfaceType, ObjectType } from '@nestjs/graphql';
import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type';
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
@InterfaceType({
resolveType(product: BillingProductDTO) {
return product.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION
return product.metadata.priceUsageBased === BillingUsageType.METERED
? BillingMeteredProduct
: BillingLicensedProduct;
},
@@ -2,8 +2,8 @@ import { Field, Float, ObjectType } from '@nestjs/graphql';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
@ObjectType('BillingMeteredProductUsage')
export class BillingMeteredProductUsageDTO {
@ObjectType('BillingResourceCreditUsage')
export class BillingResourceCreditUsageDTO {
@Field(() => BillingProductKey)
productKey: BillingProductKey;
@@ -5,8 +5,6 @@ import { registerEnumType } from '@nestjs/graphql';
export enum BillingProductKey {
BASE_PRODUCT = 'BASE_PRODUCT',
RESOURCE_CREDIT = 'RESOURCE_CREDIT',
// @deprecated — replaced by RESOURCE_CREDIT, kept while IS_BILLING_V2_ENABLED is not universal
WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION',
}
registerEnumType(BillingProductKey, {
@@ -1,60 +0,0 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'twenty-shared/types';
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
@Injectable()
export class BillingUsageEventListener {
constructor(
private readonly billingUsageService: BillingUsageService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
) {}
@OnCustomBatchEvent(USAGE_RECORDED)
async handleUsageRecordedEvent(
payload: CustomWorkspaceEventBatch<UsageEvent>,
) {
if (!isDefined(payload.workspaceId)) {
return;
}
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
const canFeatureBeUsed = await this.billingUsageService.canFeatureBeUsed(
payload.workspaceId,
);
if (!canFeatureBeUsed) {
return;
}
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
payload.workspaceId,
);
if (isV2) {
// V2: ClickHouse is the sole record; no Stripe meter events needed
return;
}
//TODO: To be removed
await this.billingUsageService.billUsage({
workspaceId: payload.workspaceId,
usageEvents: payload.events,
});
}
}
@@ -3,39 +3,21 @@
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 { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.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';
describe('BillingCreditRolloverService', () => {
let service: BillingCreditRolloverService;
let stripeCreditGrantService: jest.Mocked<StripeCreditGrantService>;
let stripeBillingMeterEventService: jest.Mocked<StripeBillingMeterEventService>;
let billingUsageService: jest.Mocked<
Pick<BillingUsageService, 'getCurrentPeriodCreditsUsed'>
>;
let billingCustomerRepository: jest.Mocked<{ update: jest.Mock }>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingCreditRolloverService,
{
provide: StripeCreditGrantService,
useValue: {
createCreditGrant: jest.fn(),
listCreditGrants: jest.fn().mockResolvedValue([]),
voidCreditGrant: jest.fn(),
getCustomerCreditBalance: jest.fn().mockResolvedValue(0),
},
},
{
provide: StripeBillingMeterEventService,
useValue: {
sumMeterEvents: jest.fn(),
},
},
{
provide: BillingUsageService,
useValue: {
@@ -54,132 +36,54 @@ describe('BillingCreditRolloverService', () => {
service = module.get<BillingCreditRolloverService>(
BillingCreditRolloverService,
);
stripeCreditGrantService = module.get(StripeCreditGrantService);
stripeBillingMeterEventService = module.get(StripeBillingMeterEventService);
billingUsageService = module.get(BillingUsageService);
billingCustomerRepository = module.get(
getRepositoryToken(BillingCustomerEntity),
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('processRolloverOnPeriodTransition', () => {
const baseParams = {
workspaceId: 'ws_123',
stripeCustomerId: 'cus_123',
subscriptionId: 'sub_123',
stripeMeterId: 'meter_123',
previousPeriodStart: new Date('2024-01-01'),
previousPeriodEnd: new Date('2024-02-01'),
newPeriodEnd: new Date('2024-03-01'),
tierQuantity: 1000,
unitPriceCents: 10,
previousPeriodStart: new Date('2024-01-01'),
};
it('should create rollover grant for unused credits', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(300);
it('writes rollover amount to creditBalanceMicro when credits unused', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(300);
await service.processRolloverOnPeriodTransition(baseParams);
expect(stripeCreditGrantService.createCreditGrant).toHaveBeenCalledWith({
customerId: 'cus_123',
creditUnits: 700, // 1000 - 300 = 700 unused
unitPriceCents: 10,
expiresAt: baseParams.newPeriodEnd,
metadata: {
type: 'rollover',
fromPeriodStart: baseParams.previousPeriodStart.toISOString(),
fromPeriodEnd: baseParams.previousPeriodEnd.toISOString(),
subscriptionId: 'sub_123',
},
});
});
it('should cap rollover at tier quantity when all credits unused', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(0);
await service.processRolloverOnPeriodTransition(baseParams);
expect(stripeCreditGrantService.createCreditGrant).toHaveBeenCalledWith(
expect.objectContaining({
creditUnits: 1000, // Capped at tierQuantity
}),
);
});
it('should not create grant when all credits used', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(1000);
await service.processRolloverOnPeriodTransition(baseParams);
expect(stripeCreditGrantService.createCreditGrant).not.toHaveBeenCalled();
});
it('should not create grant when usage exceeds tier', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(1500);
await service.processRolloverOnPeriodTransition(baseParams);
expect(stripeCreditGrantService.createCreditGrant).not.toHaveBeenCalled();
});
it('should void existing rollover grants before creating new one', async () => {
const existingGrants = [
{
id: 'grant_old',
metadata: { type: 'rollover' },
voided_at: null,
},
{
id: 'grant_other',
metadata: { type: 'promotional' },
voided_at: null,
},
{
id: 'grant_voided',
metadata: { type: 'rollover' },
voided_at: 123456,
},
] as unknown as Stripe.Billing.CreditGrant[];
stripeCreditGrantService.listCreditGrants.mockResolvedValue(
existingGrants,
);
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(500);
await service.processRolloverOnPeriodTransition(baseParams);
// Should only void the active rollover grant, not promotional or already voided
expect(stripeCreditGrantService.voidCreditGrant).toHaveBeenCalledTimes(1);
expect(stripeCreditGrantService.voidCreditGrant).toHaveBeenCalledWith(
'grant_old',
);
// Should create the new grant after voiding
expect(stripeCreditGrantService.createCreditGrant).toHaveBeenCalledWith(
expect.objectContaining({
creditUnits: 500, // 1000 - 500 = 500 unused
}),
);
});
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 },
{ creditBalanceMicro: 700 },
);
});
it('should persist credit balance even when no grant is created', async () => {
stripeBillingMeterEventService.sumMeterEvents.mockResolvedValue(1000);
stripeCreditGrantService.getCustomerCreditBalance.mockResolvedValue(0);
it('sets creditBalanceMicro to tierQuantity when no credits used', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(0);
await service.processRolloverOnPeriodTransition(baseParams);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 1000 },
);
});
it('sets creditBalanceMicro to 0 when all credits used', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(1000);
await service.processRolloverOnPeriodTransition(baseParams);
@@ -188,5 +92,32 @@ describe('BillingCreditRolloverService', () => {
{ creditBalanceMicro: 0 },
);
});
it('sets creditBalanceMicro to 0 when usage exceeds tier', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(1500);
await service.processRolloverOnPeriodTransition(baseParams);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 0 },
);
});
it('caps rollover at tierQuantity', async () => {
(
billingUsageService.getCurrentPeriodCreditsUsed as jest.Mock
).mockResolvedValue(0);
const params = { ...baseParams, tierQuantity: 500 };
await service.processRolloverOnPeriodTransition(params);
expect(billingCustomerRepository.update).toHaveBeenCalledWith(
{ stripeCustomerId: 'cus_123' },
{ creditBalanceMicro: 500 },
);
});
});
});
@@ -6,30 +6,15 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
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 { 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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-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: [
@@ -41,10 +26,9 @@ describe('BillingUsageCapService', () => {
},
},
{
provide: MeteredCreditService,
provide: ResourceCreditService,
useValue: {
extractMeteredPricingInfoFromSubscription: jest.fn(),
getCreditBalance: jest.fn(),
extractResourceCreditPricingInfo: jest.fn(),
},
},
{
@@ -53,12 +37,6 @@ describe('BillingUsageCapService', () => {
get: jest.fn(),
},
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
provide: getRepositoryToken(BillingSubscriptionItemEntity),
useValue: {
@@ -71,7 +49,6 @@ describe('BillingUsageCapService', () => {
service = module.get<BillingUsageCapService>(BillingUsageCapService);
clickHouseService = module.get(ClickHouseService);
meteredCreditService = module.get(MeteredCreditService);
twentyConfigService = module.get(TwentyConfigService);
});
@@ -157,117 +134,4 @@ describe('BillingUsageCapService', () => {
expect(result.get('ws_1')).toBe(9876543210);
});
});
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,253 +0,0 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.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 { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
describe('MeteredCreditService', () => {
let service: MeteredCreditService;
let billingSubscriptionRepository: jest.Mocked<any>;
let billingSubscriptionItemService: jest.Mocked<BillingSubscriptionItemService>;
let stripeBillingAlertService: jest.Mocked<StripeBillingAlertService>;
let stripeCreditGrantService: jest.Mocked<StripeCreditGrantService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MeteredCreditService,
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: {
findOne: jest.fn(),
find: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingPriceEntity),
useValue: {
findOneOrFail: jest.fn(),
},
},
{
provide: BillingSubscriptionItemService,
useValue: {
getMeteredSubscriptionItemDetails: jest.fn(),
},
},
{
provide: StripeBillingAlertService,
useValue: {
createUsageThresholdAlertForCustomerMeter: jest.fn(),
},
},
{
provide: StripeCreditGrantService,
useValue: {
getCustomerCreditBalance: jest.fn(),
},
},
],
}).compile();
service = module.get<MeteredCreditService>(MeteredCreditService);
billingSubscriptionRepository = module.get(
getRepositoryToken(BillingSubscriptionEntity),
);
billingSubscriptionItemService = module.get(BillingSubscriptionItemService);
stripeBillingAlertService = module.get(StripeBillingAlertService);
stripeCreditGrantService = module.get(StripeCreditGrantService);
});
describe('getMeteredPricingInfo', () => {
const createMockSubscription = (meteredTiers: any[] | null = null) => ({
id: 'sub_123',
billingSubscriptionItems: [
{
stripePriceId: 'price_123',
billingProduct: {
metadata: { productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION },
billingPrices: [
{
stripePriceId: 'price_123',
stripeMeterId: 'meter_123',
tiers: meteredTiers ?? [
{
up_to: 1000,
flat_amount: null,
unit_amount: null,
unit_amount_decimal: null,
},
{
up_to: null,
flat_amount: null,
unit_amount: null,
unit_amount_decimal: '10',
},
],
},
],
},
},
],
});
it('should return pricing info when subscription and metered item found', async () => {
const mockSubscription = createMockSubscription();
billingSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
const result = await service.getMeteredPricingInfo('sub_123');
expect(result).toEqual({
tierCap: 1000,
unitPriceCents: 10,
stripeMeterId: 'meter_123',
});
});
it('should return null when subscription not found', async () => {
billingSubscriptionRepository.findOne.mockResolvedValue(null);
const result = await service.getMeteredPricingInfo('sub_123');
expect(result).toBeNull();
});
it('should return null when metered item not found', async () => {
const mockSubscription = {
id: 'sub_123',
billingSubscriptionItems: [
{
billingProduct: {
metadata: { productKey: 'other_product' },
billingPrices: [],
},
},
],
};
billingSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
const result = await service.getMeteredPricingInfo('sub_123');
expect(result).toBeNull();
});
});
describe('getMeteredRolloverParameters', () => {
it('should return parameters when metered item found', async () => {
billingSubscriptionItemService.getMeteredSubscriptionItemDetails.mockResolvedValue(
[
{
stripeSubscriptionItemId: 'si_123',
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
stripeMeterId: 'meter_123',
tierQuantity: 5000,
unitPriceCents: 5,
freeTrialQuantity: 100,
},
],
);
const result = await service.getMeteredRolloverParameters('sub_123');
expect(result).toEqual({
stripeMeterId: 'meter_123',
tierQuantity: 5000,
unitPriceCents: 5,
});
});
it('should return null when metered item not found', async () => {
billingSubscriptionItemService.getMeteredSubscriptionItemDetails.mockResolvedValue(
[],
);
const result = await service.getMeteredRolloverParameters('sub_123');
expect(result).toBeNull();
});
});
describe('recreateBillingAlertForSubscription', () => {
it('should create billing alert with correct parameters', async () => {
const currentPeriodStart = new Date('2024-01-01');
const mockSubscription = {
id: 'sub_123',
stripeCustomerId: 'cus_123',
currentPeriodStart,
status: SubscriptionStatus.Active,
billingSubscriptionItems: [
{
stripePriceId: 'price_123',
billingProduct: {
metadata: {
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
},
billingPrices: [
{
stripePriceId: 'price_123',
stripeMeterId: 'meter_123',
tiers: [
{
up_to: 1000,
flat_amount: null,
unit_amount: null,
unit_amount_decimal: null,
},
{
up_to: null,
flat_amount: null,
unit_amount: null,
unit_amount_decimal: '10',
},
],
},
],
},
},
],
};
// Mock for getMeteredPricingInfo (uses findOne)
billingSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
stripeCreditGrantService.getCustomerCreditBalance.mockResolvedValue(500);
await service.recreateBillingAlertForSubscription(
mockSubscription as any,
);
expect(
stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter,
).toHaveBeenCalledWith('cus_123', 1000, 500, currentPeriodStart);
});
it('should not create alert when metered pricing info not found', async () => {
const mockSubscription = {
id: 'sub_123',
stripeCustomerId: 'cus_123',
currentPeriodStart: new Date('2024-01-01'),
status: SubscriptionStatus.Active,
billingSubscriptionItems: [],
};
billingSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
await service.recreateBillingAlertForSubscription(
mockSubscription as any,
);
expect(
stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter,
).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,155 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.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 { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
describe('ResourceCreditService', () => {
let service: ResourceCreditService;
let billingSubscriptionRepository: jest.Mocked<any>;
const buildSubscriptionWithResourceCredit = (
creditAmount: number,
unitAmount = 0,
) => ({
id: 'sub_123',
billingSubscriptionItems: [
{
stripePriceId: 'price_rc_123',
billingProduct: {
metadata: { productKey: BillingProductKey.RESOURCE_CREDIT },
billingPrices: [
{
stripePriceId: 'price_rc_123',
metadata: { credit_amount: String(creditAmount) },
unitAmount,
},
],
},
},
],
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ResourceCreditService,
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingPriceEntity),
useValue: {
findOneOrFail: jest.fn(),
},
},
],
}).compile();
service = module.get<ResourceCreditService>(ResourceCreditService);
billingSubscriptionRepository = module.get(
getRepositoryToken(BillingSubscriptionEntity),
);
});
describe('extractResourceCreditPricingInfo', () => {
it('returns pricing info for a valid resource credit subscription', () => {
const subscription = buildSubscriptionWithResourceCredit(1000, 10);
const result = service.extractResourceCreditPricingInfo(
subscription as any,
);
expect(result).toEqual({ tierCap: 1000, unitPriceCents: 10 });
});
it('returns null when no RESOURCE_CREDIT item found', () => {
const subscription = {
billingSubscriptionItems: [
{
stripePriceId: 'price_base',
billingProduct: {
metadata: { productKey: BillingProductKey.BASE_PRODUCT },
billingPrices: [],
},
},
],
};
expect(
service.extractResourceCreditPricingInfo(subscription as any),
).toBeNull();
});
it('returns null when credit_amount is 0', () => {
const subscription = buildSubscriptionWithResourceCredit(0);
expect(
service.extractResourceCreditPricingInfo(subscription as any),
).toBeNull();
});
it('returns null when matching price not found', () => {
const subscription = {
billingSubscriptionItems: [
{
stripePriceId: 'price_rc_different',
billingProduct: {
metadata: { productKey: BillingProductKey.RESOURCE_CREDIT },
billingPrices: [
{
stripePriceId: 'price_rc_other',
metadata: { credit_amount: '500' },
},
],
},
},
],
};
expect(
service.extractResourceCreditPricingInfo(subscription as any),
).toBeNull();
});
});
describe('getResourceCreditRolloverParameters', () => {
it('returns parameters when resource credit item found', async () => {
const subscription = buildSubscriptionWithResourceCredit(5000, 5);
billingSubscriptionRepository.findOne.mockResolvedValue(subscription);
const result =
await service.getResourceCreditRolloverParameters('sub_123');
expect(result).toEqual({ tierQuantity: 5000, unitPriceCents: 5 });
});
it('returns null when subscription not found', async () => {
billingSubscriptionRepository.findOne.mockResolvedValue(null);
const result =
await service.getResourceCreditRolloverParameters('sub_123');
expect(result).toBeNull();
});
it('returns null when resource credit pricing info not extractable', async () => {
billingSubscriptionRepository.findOne.mockResolvedValue({
billingSubscriptionItems: [],
});
const result =
await service.getResourceCreditRolloverParameters('sub_123');
expect(result).toBeNull();
});
});
});
@@ -14,7 +14,7 @@ export const buildSubscription = ({
planKey = BillingPlanKey.PRO,
interval = SubscriptionInterval.Month,
licensedPriceId = LICENSE_PRICE_PRO_MONTH_ID,
meteredPriceId = METER_PRICE_PRO_MONTH_ID,
resourceCreditPriceId = METER_PRICE_PRO_MONTH_ID,
seats = 1,
workspaceId = 'ws_1',
stripeSubscriptionId = 'sub_1',
@@ -23,7 +23,7 @@ export const buildSubscription = ({
planKey?: BillingPlanKey;
interval?: SubscriptionInterval;
licensedPriceId?: string;
meteredPriceId?: string;
resourceCreditPriceId?: string;
seats?: number;
workspaceId?: string;
stripeSubscriptionId?: string;
@@ -51,13 +51,13 @@ export const buildSubscription = ({
},
},
{
stripeSubscriptionItemId: 'si_metered',
stripeProductId: 'prod_metered',
stripePriceId: meteredPriceId,
stripeSubscriptionItemId: 'si_resource_credit',
stripeProductId: 'prod_resource_credit',
stripePriceId: resourceCreditPriceId,
billingProduct: {
metadata: {
planKey,
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
productKey: BillingProductKey.RESOURCE_CREDIT,
priceUsageBased: BillingUsageType.METERED,
},
},
@@ -45,16 +45,17 @@ export const buildBillingPriceEntity = ({
metadata: {
planKey,
productKey: isMetered
? BillingProductKey.WORKFLOW_NODE_EXECUTION
? BillingProductKey.RESOURCE_CREDIT
: BillingProductKey.BASE_PRODUCT,
priceUsageBased: isMetered
? BillingUsageType.METERED
: BillingUsageType.LICENSED,
},
},
...(isMetered && tiers
...(isMetered
? {
tiers,
metadata: { credit_amount: '1000' },
...(tiers ? { tiers } : {}),
}
: {}),
}) as BillingPriceEntity | BillingMeterPrice;
@@ -86,7 +87,7 @@ export const arrangeBillingSubscriptionRepositoryFindOneOrFail = (
planKey?: BillingPlanKey;
interval?: SubscriptionInterval;
licensedPriceId?: string;
meteredPriceId?: string;
resourceCreditPriceId?: string;
seats?: number;
workspaceId?: string;
stripeSubscriptionId?: string;
@@ -195,13 +196,13 @@ export const arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams = (
export const buildSchedulePhase = ({
licensedPriceId,
meteredPriceId,
resourceCreditPriceId,
seats = 1,
startDate = Math.floor(Date.now() / 1000),
endDate = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
}: {
licensedPriceId: string;
meteredPriceId: string;
resourceCreditPriceId: string;
seats?: number;
startDate?: number;
endDate?: number;
@@ -211,6 +212,6 @@ export const buildSchedulePhase = ({
end_date: endDate,
items: [
{ price: licensedPriceId, quantity: seats },
{ price: meteredPriceId },
{ price: resourceCreditPriceId, quantity: 1 },
],
}) as Stripe.SubscriptionSchedule.Phase;
@@ -7,78 +7,16 @@ import { Repository } from 'typeorm';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.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';
@Injectable()
export class BillingCreditRolloverService {
constructor(
private readonly stripeCreditGrantService: StripeCreditGrantService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
private readonly billingUsageService: BillingUsageService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
) {}
async processRolloverOnPeriodTransition({
stripeCustomerId,
subscriptionId,
stripeMeterId,
previousPeriodStart,
previousPeriodEnd,
newPeriodEnd,
tierQuantity,
unitPriceCents,
}: {
stripeCustomerId: string;
subscriptionId: string;
stripeMeterId: string;
previousPeriodStart: Date;
previousPeriodEnd: Date;
newPeriodEnd: Date;
tierQuantity: number;
unitPriceCents: number;
}): Promise<void> {
// Void any existing rollover grants before creating a new one
// This ensures only one rollover grant is active at a time
await this.voidExistingRolloverGrants(stripeCustomerId);
const usedCredits =
await this.stripeBillingMeterEventService.sumMeterEvents(
stripeMeterId,
stripeCustomerId,
previousPeriodStart,
previousPeriodEnd,
);
const unusedCredits = Math.max(0, tierQuantity - usedCredits);
if (unusedCredits <= 0) {
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
return;
}
const rolloverAmount = Math.min(unusedCredits, tierQuantity);
await this.stripeCreditGrantService.createCreditGrant({
customerId: stripeCustomerId,
creditUnits: rolloverAmount,
unitPriceCents,
expiresAt: newPeriodEnd,
metadata: {
type: 'rollover',
fromPeriodStart: previousPeriodStart.toISOString(),
fromPeriodEnd: previousPeriodEnd.toISOString(),
subscriptionId,
},
});
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
}
// V2 path — reads usedCredits from ClickHouse; writes rollover directly to creditBalanceMicro
async processRolloverOnPeriodTransitionV2({
workspaceId,
stripeCustomerId,
tierQuantity,
@@ -103,39 +41,4 @@ export class BillingCreditRolloverService {
{ creditBalanceMicro: rolloverAmount },
);
}
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(
stripeCustomerId: string,
): Promise<void> {
const existingGrants =
await this.stripeCreditGrantService.listCreditGrants(stripeCustomerId);
const rolloverGrants = existingGrants.filter(
(grant) => grant.metadata?.type === 'rollover' && !grant.voided_at,
);
if (rolloverGrants.length === 0) {
return;
}
for (const grant of rolloverGrants) {
await this.stripeCreditGrantService.voidCreditGrant(grant.id);
}
}
}
@@ -17,7 +17,6 @@ import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.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';
@@ -26,14 +25,10 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
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 { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FeatureFlagKey } from 'twenty-shared/types';
@Injectable()
export class BillingPortalWorkspaceService {
@@ -43,14 +38,12 @@ export class BillingPortalWorkspaceService {
private readonly stripeBillingPortalService: StripeBillingPortalService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly twentyConfigService: TwentyConfigService,
) {}
async computeCheckoutSessionURL({
@@ -168,12 +161,11 @@ export class BillingPortalWorkspaceService {
relations: ['billingSubscriptions'],
});
const stripeSubscriptionLineItems =
await this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId: workspace.id,
});
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId: workspace.id,
});
return {
successUrl,
@@ -263,39 +255,6 @@ export class BillingPortalWorkspaceService {
return session.url;
}
private getDefaultMeteredProductPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
): BillingMeterPrice {
const defaultMeteredProductPrice =
billingPricesPerPlan.meteredProductPrices.reduce(
(result, billingPrice) => {
if (!result) {
return billingPrice as BillingMeterPrice;
}
const tiers = billingPrice.tiers;
if (billingValidator.isMeteredTiersSchema(tiers)) {
if (tiers[0].flat_amount < result.tiers[0].flat_amount) {
return billingPrice as BillingMeterPrice;
}
}
return result;
},
null as BillingMeterPrice | null,
);
if (!isDefined(defaultMeteredProductPrice)) {
throw new BillingException(
'Missing Default Metered price',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return defaultMeteredProductPrice;
}
// V2 path — finds the lowest credit_amount RESOURCE_CREDIT licensed price as default
private getDefaultResourceCreditPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
) {
@@ -317,24 +276,14 @@ export class BillingPortalWorkspaceService {
});
}
private async getStripeSubscriptionLineItems({
private getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId,
}: {
quantity: number;
billingPricesPerPlan: BillingGetPricesPerPlanResult;
workspaceId: string;
}): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
const isBillingV2EnabledForNewWorkspaces = this.twentyConfigService.get(
'IS_BILLING_V2_ENABLED_FOR_NEW_WORKSPACES',
);
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
const defaultBaseProductPrice = findOrThrow(
billingPricesPerPlan.baseProductPrices,
(baseProductPrice) =>
@@ -346,24 +295,8 @@ export class BillingPortalWorkspaceService {
),
);
if (isBillingV2EnabledForNewWorkspaces || isV2) {
const defaultResourceCreditPrice =
this.getDefaultResourceCreditPrice(billingPricesPerPlan);
return [
{
price: defaultBaseProductPrice.stripePriceId,
quantity,
},
{
price: defaultResourceCreditPrice.stripePriceId,
quantity: 1,
},
];
}
const defaultMeteredProductPrice =
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
const defaultResourceCreditPrice =
this.getDefaultResourceCreditPrice(billingPricesPerPlan);
return [
{
@@ -371,7 +304,8 @@ export class BillingPortalWorkspaceService {
quantity,
},
{
price: defaultMeteredProductPrice.stripePriceId,
price: defaultResourceCreditPrice.stripePriceId,
quantity: 1,
},
];
}
@@ -8,7 +8,6 @@ import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
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';
@@ -24,43 +23,6 @@ export class BillingSubscriptionItemService {
private readonly twentyConfigService: TwentyConfigService,
) {}
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
const meteredSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
where: {
billingSubscriptionId: subscriptionId,
},
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
return meteredSubscriptionItems.reduce(
(acc, item) => {
const price = this.findMatchingPrice(item);
if (!price.stripeMeterId) {
return acc;
}
return acc.concat({
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: item.billingProduct.metadata.productKey,
stripeMeterId: price.stripeMeterId,
tierQuantity: this.getTierQuantity(price),
freeTrialQuantity: this.getFreeTrialQuantity(item),
unitPriceCents: this.getUnitPrice(price),
});
},
[] as Array<{
stripeSubscriptionItemId: string;
productKey: BillingProductKey;
stripeMeterId: string;
tierQuantity: number;
freeTrialQuantity: number;
unitPriceCents: number;
}>,
);
}
async getResourceCreditSubscriptionItemDetails(
subscription: BillingSubscriptionEntity,
): Promise<{
@@ -128,27 +90,4 @@ export class BillingSubscriptionItemService {
return matchingPrice;
}
private getTierQuantity(price: BillingPriceEntity): number {
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return price.tiers[0].up_to;
}
private getFreeTrialQuantity(item: BillingSubscriptionItemEntity): number {
switch (item.billingProduct.metadata.productKey) {
case BillingProductKey.WORKFLOW_NODE_EXECUTION:
return this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
);
default:
return 0;
}
}
private getUnitPrice(price: BillingPriceEntity): number {
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return Number(price.tiers[1].unit_amount_decimal);
}
}
@@ -83,52 +83,27 @@ export class BillingSubscriptionPhaseService {
} as Stripe.SubscriptionScheduleUpdateParams.Phase;
}
async buildPhaseUpdateParams({
buildPhaseUpdateParams({
toUpdatePrices,
startDate,
endDate,
isV2,
}: {
toUpdatePrices: SubscriptionStripePrices;
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
isV2: boolean;
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
if (isV2) {
assertIsDefinedOrThrow(toUpdatePrices.resourceCreditPriceId);
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{ price: toUpdatePrices.resourceCreditPriceId, quantity: 1 },
],
};
} else {
assertIsDefinedOrThrow(toUpdatePrices.meteredPriceId);
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{
price: toUpdatePrices.meteredPriceId,
},
],
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
toUpdatePrices.meteredPriceId,
),
};
}
}): Stripe.SubscriptionScheduleUpdateParams.Phase {
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{ price: toUpdatePrices.resourceCreditPriceId, quantity: 1 },
],
};
}
getLicensedPriceIdAndQuantityFromPhaseUpdateParams(
@@ -145,16 +120,6 @@ export class BillingSubscriptionPhaseService {
};
}
getMeteredPriceIdFromPhaseUpdateParams(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
const meteredItem = findOrThrow(phase.items!, (i) => i.quantity == null);
assertIsDefinedOrThrow(meteredItem.price);
return meteredItem.price;
}
async isSamePhaseSignature(
a: Stripe.SubscriptionScheduleUpdateParams.Phase,
b: Stripe.SubscriptionScheduleUpdateParams.Phase,
@@ -164,24 +129,22 @@ export class BillingSubscriptionPhaseService {
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(a);
const phaseBLicensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(b);
const phaseAMeteredPriceId =
this.getMeteredPriceIdFromPhaseUpdateParams(a);
const phaseBMeteredPriceId =
this.getMeteredPriceIdFromPhaseUpdateParams(b);
const phaseAResourceCreditPriceId =
this.getResourceCreditPriceIdFromPhaseUpdateParams(a);
const phaseBResourceCreditPriceId =
this.getResourceCreditPriceIdFromPhaseUpdateParams(b);
return (
phaseALicensedPriceIdAndQuantity.price ===
phaseBLicensedPriceIdAndQuantity.price &&
phaseALicensedPriceIdAndQuantity.quantity ===
phaseBLicensedPriceIdAndQuantity.quantity &&
phaseAMeteredPriceId === phaseBMeteredPriceId
phaseAResourceCreditPriceId === phaseBResourceCreditPriceId
);
} catch {
return false;
}
}
// Billing V2: emits { price, quantity: 1 } for the resource credit price; no billing_thresholds
async buildResourceCreditPhaseUpdateParams({
basePlanStripePriceId,
seats,
@@ -233,7 +196,6 @@ export class BillingSubscriptionPhaseService {
}
}
// Billing V2 counterpart of getMeteredPriceIdFromPhaseUpdateParams (resource credit has quantity: 1)
getResourceCreditPriceIdFromPhaseUpdateParams(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
@@ -24,8 +24,6 @@ import { BillingPriceService } from 'src/engine/core-modules/billing/services/bi
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
@@ -36,18 +34,14 @@ import {
import { computeSubscriptionUpdateOptions } from 'src/engine/core-modules/billing/utils/compute-subscription-update-options.util';
import { getBaseProductSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-base-product-subscription-item-or-throw.util';
import { getCurrentLicensedBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util';
import { getCurrentMeteredBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util';
import { getCurrentResourceCreditSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-resource-credit-subscription-item-or-throw.util';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FeatureFlagKey } from 'twenty-shared/types';
export type SubscriptionStripePrices = {
licensedPriceId: string;
seats: number;
meteredPriceId: string | undefined;
resourceCreditPriceId: string | undefined;
resourceCreditPriceId: string;
};
@Injectable()
@@ -67,55 +61,9 @@ export class BillingSubscriptionUpdateService {
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
private readonly billingSubscriptionPhaseService: BillingSubscriptionPhaseService,
private readonly stripeBillingAlertService: StripeBillingAlertService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly meteredCreditService: MeteredCreditService,
private readonly featureFlagService: FeatureFlagService,
) {}
private async isV2(workspaceId: string): Promise<boolean> {
return await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
}
async changeMeteredPrice(
workspaceId: string,
meteredPriceId: string,
): Promise<void> {
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId },
);
const subscriptionUpdate = {
type: SubscriptionUpdateType.METERED_PRICE,
newMeteredPriceId: meteredPriceId,
} as const;
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
}
async cancelSwitchMeteredPrice(workspace: WorkspaceEntity): Promise<void> {
if (await this.isV2(workspace.id)) {
return this.cancelSwitchResourceCreditPrice(workspace);
}
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
const currentMeteredPrice =
getCurrentMeteredBillingSubscriptionItemOrThrow(billingSubscription);
await this.updateSubscription(billingSubscription.id, {
type: SubscriptionUpdateType.METERED_PRICE,
newMeteredPriceId: currentMeteredPrice.stripePriceId,
});
}
async changeResourceCreditPrice(
workspaceId: string,
resourceCreditPriceId: string,
@@ -242,27 +190,17 @@ export class BillingSubscriptionUpdateService {
},
);
const isV2 = await this.isV2(subscription.workspaceId);
const licensedItem = getBaseProductSubscriptionItemOrThrow(subscription);
const resourceCreditItem =
getCurrentResourceCreditSubscriptionItemOrThrow(subscription);
const licensedItem = isV2
? getBaseProductSubscriptionItemOrThrow(subscription)
: getCurrentLicensedBillingSubscriptionItemOrThrow(subscription);
const resourceCreditItem = isV2
? getCurrentResourceCreditSubscriptionItemOrThrow(subscription)
: undefined;
const meteredItem = isV2
? undefined
: getCurrentMeteredBillingSubscriptionItemOrThrow(subscription);
const toUpdateCurrentPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
{
licensedPriceId: licensedItem.stripePriceId,
meteredPriceId: meteredItem?.stripePriceId,
resourceCreditPriceId: resourceCreditItem?.stripePriceId,
resourceCreditPriceId: resourceCreditItem.stripePriceId,
seats: licensedItem.quantity,
},
isV2,
);
const { schedule, currentPhase, nextPhase } =
@@ -294,19 +232,17 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
} else {
assertIsDefinedOrThrow(nextPhase);
assertIsDefinedOrThrow(currentPhase);
const nextPhasePrices =
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
await this.getSubscriptionPricesFromSchedulePhase(nextPhase);
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
nextPhasePrices,
isV2,
);
await this.runSubscriptionScheduleUpdate({
@@ -320,7 +256,6 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
}
} else {
@@ -341,16 +276,12 @@ export class BillingSubscriptionUpdateService {
await this.runSubscriptionUpdate({
stripeSubscriptionId: subscription.stripeSubscriptionId,
licensedStripeItemId: licensedItem.stripeSubscriptionItemId,
meteredStripeItemId: meteredItem?.stripeSubscriptionItemId,
resourceCreditStripeItemId:
resourceCreditItem?.stripeSubscriptionItemId,
resourceCreditStripeItemId: resourceCreditItem.stripeSubscriptionItemId,
licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId,
meteredStripePriceId: toUpdateCurrentPrices?.meteredPriceId,
resourceCreditStripePriceId:
toUpdateCurrentPrices?.resourceCreditPriceId,
toUpdateCurrentPrices.resourceCreditPriceId,
seats: toUpdateCurrentPrices.seats,
...subscriptionOptions,
isV2,
});
if (subscriptionUpdate.type !== SubscriptionUpdateType.SEATS) {
@@ -370,17 +301,16 @@ export class BillingSubscriptionUpdateService {
assertIsDefinedOrThrow(refreshedCurrentPhase);
const nextPhasePrices =
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
await this.getSubscriptionPricesFromSchedulePhase(nextPhase);
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
nextPhasePrices,
isV2,
);
await this.runSubscriptionScheduleUpdate({
stripeScheduleId: schedule.id,
toUpdateNextPrices,
toUpdateCurrentPrices: undefined, //subscription update causes schedule current phase update
toUpdateCurrentPrices: undefined,
currentPhase:
this.billingSubscriptionPhaseService.toPhaseUpdateParams(
refreshedCurrentPhase,
@@ -388,7 +318,6 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
}
}
@@ -441,9 +370,8 @@ export class BillingSubscriptionUpdateService {
}
}
private async getSubscriptionPricesFromSchedulePhaseV2(
private async getSubscriptionPricesFromSchedulePhase(
phase: Stripe.SubscriptionSchedule.Phase,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const licensedItemPriceIds = phase.items
.filter((item) => item.quantity != null)
@@ -469,110 +397,62 @@ export class BillingSubscriptionUpdateService {
assertIsDefinedOrThrow(basePlanPhaseItem.quantity);
if (isV2) {
const resourceCreditPrice = licensedItemPrices.find(
(price) =>
price.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
const resourceCreditPrice = licensedItemPrices.find(
(price) =>
price.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
assertIsDefinedOrThrow(resourceCreditPrice);
assertIsDefinedOrThrow(resourceCreditPrice);
return {
licensedPriceId: basePlanPrice.stripePriceId,
meteredPriceId: undefined,
seats: basePlanPhaseItem.quantity,
resourceCreditPriceId: resourceCreditPrice.stripePriceId,
};
} else {
const meteredItem = findOrThrow(
phase.items,
(item) => item.quantity == null,
);
return {
licensedPriceId: basePlanPrice.stripePriceId,
meteredPriceId: normalizePriceRef(meteredItem.price),
seats: basePlanPhaseItem.quantity,
resourceCreditPriceId: undefined,
};
}
return {
licensedPriceId: basePlanPrice.stripePriceId,
seats: basePlanPhaseItem.quantity,
resourceCreditPriceId: resourceCreditPrice.stripePriceId,
};
}
private async runSubscriptionUpdate({
stripeSubscriptionId,
licensedStripeItemId,
meteredStripeItemId,
resourceCreditStripeItemId,
licensedStripePriceId,
meteredStripePriceId,
resourceCreditStripePriceId,
seats,
anchor,
proration,
metadata,
isV2,
}: {
stripeSubscriptionId: string;
licensedStripeItemId: string;
meteredStripeItemId: string | undefined;
resourceCreditStripeItemId: string | undefined;
resourceCreditStripeItemId: string;
licensedStripePriceId: string;
meteredStripePriceId: string | undefined;
resourceCreditStripePriceId: string | undefined;
resourceCreditStripePriceId: string;
seats: number;
anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor;
proration?: Stripe.SubscriptionUpdateParams.ProrationBehavior;
metadata?: Record<string, string>;
isV2: boolean;
}) {
if (isV2) {
assertIsDefinedOrThrow(resourceCreditStripePriceId);
assertIsDefinedOrThrow(resourceCreditStripeItemId);
return await this.stripeSubscriptionService.updateSubscription(
stripeSubscriptionId,
{
items: [
{
id: licensedStripeItemId,
price: licensedStripePriceId,
quantity: seats,
},
{
id: resourceCreditStripeItemId,
price: resourceCreditStripePriceId,
quantity: 1,
},
],
...(anchor ? { billing_cycle_anchor: anchor } : {}),
...(proration ? { proration_behavior: proration } : {}),
...(metadata ? { metadata } : {}),
},
);
} else {
assertIsDefinedOrThrow(meteredStripePriceId);
assertIsDefinedOrThrow(meteredStripeItemId);
return await this.stripeSubscriptionService.updateSubscription(
stripeSubscriptionId,
{
items: [
{
id: licensedStripeItemId,
price: licensedStripePriceId,
quantity: seats,
},
{ id: meteredStripeItemId, price: meteredStripePriceId },
],
...(anchor ? { billing_cycle_anchor: anchor } : {}),
...(proration ? { proration_behavior: proration } : {}),
...(metadata ? { metadata } : {}),
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meteredStripePriceId,
),
},
);
}
return await this.stripeSubscriptionService.updateSubscription(
stripeSubscriptionId,
{
items: [
{
id: licensedStripeItemId,
price: licensedStripePriceId,
quantity: seats,
},
{
id: resourceCreditStripeItemId,
price: resourceCreditStripePriceId,
quantity: 1,
},
],
...(anchor ? { billing_cycle_anchor: anchor } : {}),
...(proration ? { proration_behavior: proration } : {}),
...(metadata ? { metadata } : {}),
},
);
}
async runSubscriptionScheduleUpdate({
@@ -581,14 +461,12 @@ export class BillingSubscriptionUpdateService {
toUpdateCurrentPrices,
currentPhase,
subscriptionCurrentPeriodEnd,
isV2,
}: {
stripeScheduleId: string;
toUpdateNextPrices: SubscriptionStripePrices;
toUpdateCurrentPrices: SubscriptionStripePrices | undefined;
currentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase;
subscriptionCurrentPeriodEnd: number;
isV2: boolean;
}) {
let toUpdateCurrentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
...currentPhase,
@@ -601,7 +479,6 @@ export class BillingSubscriptionUpdateService {
toUpdatePrices: toUpdateCurrentPrices,
endDate: subscriptionCurrentPeriodEnd,
startDate: currentPhase.start_date,
isV2,
});
}
@@ -610,7 +487,6 @@ export class BillingSubscriptionUpdateService {
toUpdatePrices: toUpdateNextPrices,
startDate: subscriptionCurrentPeriodEnd,
endDate: undefined,
isV2,
});
if (
@@ -648,35 +524,6 @@ export class BillingSubscriptionUpdateService {
return isDowngrade;
}
case SubscriptionUpdateType.METERED_PRICE: {
const currentMeteredPriceId =
subscription.billingSubscriptionItems.find(
(item) => item.quantity == null,
)?.stripePriceId;
assertIsDefinedOrThrow(currentMeteredPriceId);
const currentMeteredPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentMeteredPriceId },
relations: ['billingProduct'],
});
const newMeteredPrice = await this.billingPriceRepository.findOneOrFail(
{
where: { stripePriceId: update.newMeteredPriceId },
relations: ['billingProduct'],
},
);
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
billingValidator.assertIsMeteredPrice(newMeteredPrice);
const currentMeteredCap = currentMeteredPrice.tiers[0].up_to;
const newMeteredCap = newMeteredPrice.tiers[0].up_to;
const isDowngrade = currentMeteredCap > newMeteredCap;
return isDowngrade;
}
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE: {
const currentResourceCreditPriceId =
subscription.billingSubscriptionItems.find(
@@ -737,19 +584,12 @@ export class BillingSubscriptionUpdateService {
async computeSubscriptionPricesUpdate(
update: SubscriptionUpdate,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
switch (update.type) {
case SubscriptionUpdateType.PLAN:
return await this.computeSubscriptionPricesUpdateByPlan(
update.newPlan,
currentPrices,
isV2,
);
case SubscriptionUpdateType.METERED_PRICE:
return await this.computeSubscriptionPricesUpdateByMeteredPrice(
update.newMeteredPriceId,
currentPrices,
);
case SubscriptionUpdateType.SEATS:
return this.computeSubscriptionPricesUpdateBySeats(
@@ -760,7 +600,6 @@ export class BillingSubscriptionUpdateService {
return await this.computeSubscriptionPricesUpdateByInterval(
update.newInterval,
currentPrices,
isV2,
);
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
return await this.computeSubscriptionPricesUpdateByResourceCreditPrice(
@@ -780,52 +619,6 @@ export class BillingSubscriptionUpdateService {
};
}
private async computeSubscriptionPricesUpdateByMeteredPrice(
newMeteredPriceId: string,
currentPrices: SubscriptionStripePrices,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.licensedPriceId },
relations: ['billingProduct'],
});
const currentInterval = currentLicensedPrice.interval;
const currentPlanKey =
currentLicensedPrice.billingProduct?.metadata.planKey;
assertIsDefinedOrThrow(currentPlanKey);
const newMeteredPrice = await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: newMeteredPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(newMeteredPrice);
const newInterval = newMeteredPrice.interval;
const newPlanKey = newMeteredPrice.billingProduct?.metadata.planKey;
if (newInterval === currentInterval && currentPlanKey === newPlanKey) {
return {
...currentPrices,
meteredPriceId: newMeteredPriceId,
};
}
const newEquivalentMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: newMeteredPrice,
targetInterval: currentInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: newInterval === currentInterval,
hasSamePlanKey: currentPlanKey === newPlanKey,
});
return {
...currentPrices,
meteredPriceId: newEquivalentMeteredPrice.stripePriceId,
};
}
private async computeSubscriptionPricesUpdateByResourceCreditPrice(
newResourceCreditPriceId: string,
currentPrices: SubscriptionStripePrices,
@@ -879,7 +672,6 @@ export class BillingSubscriptionUpdateService {
private async computeSubscriptionPricesUpdateByPlan(
newPlan: BillingPlanKey,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
@@ -909,61 +701,35 @@ export class BillingSubscriptionUpdateService {
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
if (isV2) {
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
const targetResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: currentResourceCreditPrice,
targetInterval: currentInterval,
targetPlanKey: newPlan,
hasSameInterval: true,
hasSamePlanKey: false,
});
const targetResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: currentResourceCreditPrice,
targetInterval: currentInterval,
targetPlanKey: newPlan,
hasSameInterval: true,
hasSamePlanKey: false,
});
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
};
} else {
const currentMeteredPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.meteredPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
const targetMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: currentMeteredPrice,
targetInterval: currentInterval,
targetPlanKey: newPlan,
hasSameInterval: true,
hasSamePlanKey: false,
});
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
meteredPriceId: targetMeteredPrice.stripePriceId,
};
}
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
};
}
private async computeSubscriptionPricesUpdateByInterval(
newInterval: SubscriptionInterval,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
@@ -993,54 +759,29 @@ export class BillingSubscriptionUpdateService {
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
if (isV2) {
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
const targetResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: currentResourceCreditPrice,
targetInterval: newInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: false,
hasSamePlanKey: true,
});
const targetResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: currentResourceCreditPrice,
targetInterval: newInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: false,
hasSamePlanKey: true,
});
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
};
} else {
const currentMeteredPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.meteredPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
const targetMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: currentMeteredPrice,
targetInterval: newInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: false,
hasSamePlanKey: true,
});
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
meteredPriceId: targetMeteredPrice.stripePriceId,
};
}
return {
...currentPrices,
licensedPriceId: targetLicensedPrice.stripePriceId,
resourceCreditPriceId: targetResourceCreditPrice.stripePriceId,
};
}
}
@@ -28,7 +28,6 @@ import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/bil
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
@@ -56,7 +55,6 @@ export class BillingSubscriptionService {
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingSubscriptionEntity>,
private readonly meteredCreditService: MeteredCreditService,
private readonly enterprisePlanService: EnterprisePlanService,
) {}
@@ -252,10 +250,6 @@ export class BillingSubscriptionService {
{ hasReachedCurrentPeriodCap: false },
);
await this.meteredCreditService.recreateBillingAlertForSubscription(
billingSubscription,
);
return {
status: getSubscriptionStatus(updatedSubscription.status),
hasPaymentMethod: true,
@@ -10,13 +10,10 @@ import {
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
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 { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FeatureFlagKey } from 'twenty-shared/types';
import { Not, Raw, Repository } from 'typeorm';
export type BillingCapEvaluation =
@@ -42,9 +39,8 @@ type BatchUsageSumRow = {
export class BillingUsageCapService {
constructor(
private readonly clickHouseService: ClickHouseService,
private readonly meteredCreditService: MeteredCreditService,
private readonly resourceCreditService: ResourceCreditService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(BillingSubscriptionItemEntity)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
) {}
@@ -86,99 +82,10 @@ export class BillingUsageCapService {
return result;
}
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;
}
// V2 path — uses extractResourceCreditPricingInfo (productKey === RESOURCE_CREDIT)
// instead of extractMeteredPricingInfoFromSubscription (productKey === WORKFLOW_NODE_EXECUTION)
evaluateCapBatchV2(
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 resourceCreditPricingInfo =
this.meteredCreditService.extractResourceCreditPricingInfo(
subscription,
);
if (!resourceCreditPricingInfo) {
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 = resourceCreditPricingInfo.tierCap + creditBalance;
results.set(subscription.id, {
skipped: false,
hasReachedCap: usage >= allowance,
usage,
allowance,
tierCap: resourceCreditPricingInfo.tierCap,
creditBalance,
});
}
return results;
}
async setSubscriptionItemHasReachedCap(
workspaceId: string,
hasReachedCap: boolean,
): Promise<void> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
const productKey = isV2
? BillingProductKey.RESOURCE_CREDIT
: BillingProductKey.WORKFLOW_NODE_EXECUTION;
const billingSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
where: {
@@ -189,7 +96,7 @@ export class BillingUsageCapService {
billingProduct: {
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
metadata: JSON.stringify({
productKey,
productKey: BillingProductKey.RESOURCE_CREDIT,
}),
}),
},
@@ -13,7 +13,7 @@ import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { type BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
import { type BillingResourceCreditUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-resource-credit-usage.dto';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.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';
@@ -21,19 +21,13 @@ import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billin
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.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 { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.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';
import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modules/billing/utils/build-billing-usage-available-credits-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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { FeatureFlagKey } from 'twenty-shared/types';
type UsageSumRow = {
total: string | number | null;
@@ -46,19 +40,15 @@ export class BillingUsageService {
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
private readonly twentyConfigService: TwentyConfigService,
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
private readonly stripeCreditGrantService: StripeCreditGrantService,
@InjectCacheStorage(CacheStorageNamespace.EngineBillingUsage)
private readonly billingUsageCacheStorage: CacheStorageService,
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
private readonly meteredCreditService: MeteredCreditService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly clickHouseService: ClickHouseService,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly featureFlagService: FeatureFlagService,
) {}
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
@@ -74,76 +64,9 @@ export class BillingUsageService {
return !!billingSubscription;
}
//TODO: TO be deprecated
async billUsage({
workspaceId,
usageEvents,
}: {
workspaceId: string;
usageEvents: UsageEvent[];
}) {
const workspaceStripeCustomer =
await this.billingCustomerRepository.findOne({
where: {
workspaceId,
},
});
if (!workspaceStripeCustomer) {
throw new BillingException(
'Stripe customer not found',
BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND,
);
}
try {
await Promise.all(
usageEvents.map((usageEvent) =>
this.stripeBillingMeterEventService.sendBillingMeterEvent({
usageEvent,
stripeCustomerId: workspaceStripeCustomer.stripeCustomerId,
}),
),
);
} catch (error) {
throw new BillingException(
`Failed to send billing meter events to Stripe: ${error}`,
BillingExceptionCode.BILLING_METER_EVENT_FAILED,
);
}
}
//TODO: TO be deprecated
async getMeteredProductsUsage(
workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
const meteredSubscriptionItemDetails =
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
subscription.id,
);
const { periodStart, periodEnd } = this.getSubscriptionPeriod(subscription);
return Promise.all(
meteredSubscriptionItemDetails.map((item) =>
this.buildMeteredProductUsage(
subscription,
item,
periodStart,
periodEnd,
),
),
);
}
async getResourceCreditProductUsage(
workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
): Promise<BillingResourceCreditUsageDTO[]> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
@@ -186,7 +109,7 @@ export class BillingUsageService {
>,
periodStart: Date,
periodEnd: Date,
): Promise<BillingMeteredProductUsageDTO> {
): Promise<BillingResourceCreditUsageDTO> {
const usedCredits = await this.getCurrentPeriodCreditsUsed(
workspaceId,
periodStart,
@@ -214,7 +137,6 @@ export class BillingUsageService {
};
}
//TODO: TO be deprecated
private getSubscriptionPeriod(subscription: BillingSubscriptionEntity): {
periodStart: Date;
periodEnd: Date;
@@ -237,48 +159,6 @@ export class BillingUsageService {
};
}
//TODO: TO be deprecated
private async buildMeteredProductUsage(
subscription: BillingSubscriptionEntity,
item: Awaited<
ReturnType<
typeof this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails
>
>[number],
periodStart: Date,
periodEnd: Date,
): Promise<BillingMeteredProductUsageDTO> {
const meterEventsSum =
await this.stripeBillingMeterEventService.sumMeterEvents(
item.stripeMeterId,
subscription.stripeCustomerId,
periodStart,
periodEnd,
);
const grantedCredits =
subscription.status === SubscriptionStatus.Trialing
? item.freeTrialQuantity
: item.tierQuantity;
const rolloverCredits =
await this.stripeCreditGrantService.getCustomerCreditBalance(
subscription.stripeCustomerId,
item.unitPriceCents,
);
return {
productKey: item.productKey,
periodStart,
periodEnd,
usedCredits: meterEventsSum,
grantedCredits,
rolloverCredits,
totalGrantedCredits: grantedCredits + rolloverCredits,
unitPriceCents: item.unitPriceCents,
};
}
async flushAvailableCreditsFromCache(workspaceId: string): Promise<void> {
await this.billingUsageCacheStorage.flushByPattern(
`available-credits:${workspaceId}:*`,
@@ -332,50 +212,20 @@ export class BillingUsageService {
);
}
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
const resourceUsageCap = this.getResourceUsageCap(subscription);
const { creditBalanceMicro: creditBalance } =
await this.billingCustomerRepository.findOneOrFail({
select: { creditBalanceMicro: true },
where: { workspaceId },
});
const usage = await this.getCurrentPeriodCreditsUsed(
subscription.workspaceId,
subscription.currentPeriodStart,
);
if (isV2) {
const resourceUsageCap = this.getResourceUsageCap(subscription);
const { creditBalanceMicro: creditBalance } =
await this.billingCustomerRepository.findOneOrFail({
select: { creditBalanceMicro: true },
where: { workspaceId },
});
const usage = await this.getCurrentPeriodCreditsUsed(
subscription.workspaceId,
subscription.currentPeriodStart,
);
return resourceUsageCap + creditBalance - usage;
} else {
const meteredPricingInfo =
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
subscription,
);
if (!meteredPricingInfo) {
throw new BillingException(
`No metered item found for workspace ${workspaceId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
const [creditBalance, usage] = await Promise.all([
this.meteredCreditService.getCreditBalance(
subscription.stripeCustomerId,
meteredPricingInfo.unitPriceCents,
),
this.getCurrentPeriodCreditsUsed(
subscription.workspaceId,
subscription.currentPeriodStart,
),
]);
return meteredPricingInfo.tierCap + creditBalance - usage;
}
return resourceUsageCap + creditBalance - usage;
}
getResourceUsageCap(subscription: BillingSubscriptionEntity): number {
@@ -1,259 +0,0 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.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 { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
export type MeteredPricingInfo = {
tierCap: number;
unitPriceCents: number;
stripeMeterId?: string;
};
export type ResourceCreditPricingInfo = {
tierCap: number;
unitPriceCents: number;
};
@Injectable()
export class MeteredCreditService {
protected readonly logger = new Logger(MeteredCreditService.name);
constructor(
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
@InjectRepository(BillingPriceEntity)
private readonly billingPriceRepository: Repository<BillingPriceEntity>,
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
private readonly stripeBillingAlertService: StripeBillingAlertService,
private readonly stripeCreditGrantService: StripeCreditGrantService,
) {}
/**
* Get metered pricing info for a subscription by looking up the metered subscription item
* and extracting tier cap and unit price from the associated price.
*/
async getMeteredPricingInfo(
subscriptionId: string,
): Promise<MeteredPricingInfo | null> {
const subscription = await this.billingSubscriptionRepository.findOne({
where: { id: subscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
'billingSubscriptionItems.billingProduct.billingPrices',
],
});
if (!isDefined(subscription)) {
return null;
}
return this.extractMeteredPricingInfoFromSubscription(subscription);
}
extractMeteredPricingInfoFromSubscription(
subscription: BillingSubscriptionEntity,
): MeteredPricingInfo | null {
const meteredItem = subscription.billingSubscriptionItems?.find(
(item) =>
item.billingProduct?.metadata?.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!isDefined(meteredItem)) {
return null;
}
const matchingPrice = meteredItem.billingProduct.billingPrices?.find(
(price) => price.stripePriceId === meteredItem.stripePriceId,
);
if (!isDefined(matchingPrice)) {
return null;
}
if (!billingValidator.isMeteredTiersSchema(matchingPrice.tiers)) {
return null;
}
return {
tierCap: matchingPrice.tiers[0].up_to,
unitPriceCents: Number(matchingPrice.tiers[1].unit_amount_decimal),
stripeMeterId: matchingPrice.stripeMeterId ?? undefined,
};
}
/**
* Get metered pricing info directly from a price ID.
*/
async getMeteredPricingInfoFromPriceId(
priceId: string,
): Promise<MeteredPricingInfo> {
const price = await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: priceId },
});
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return {
tierCap: price.tiers[0].up_to,
unitPriceCents: Number(price.tiers[1].unit_amount_decimal),
stripeMeterId: price.stripeMeterId ?? undefined,
};
}
/**
* Get metered rollover parameters for a subscription.
* Returns null if no metered item is found.
*/
async getMeteredRolloverParameters(subscriptionId: string): Promise<{
stripeMeterId: string;
tierQuantity: number;
unitPriceCents: number;
} | null> {
const meteredDetails =
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
subscriptionId,
);
const meteredItem = meteredDetails.find(
(item) => item.productKey === BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!meteredItem) {
return null;
}
return {
stripeMeterId: meteredItem.stripeMeterId,
tierQuantity: meteredItem.tierQuantity,
unitPriceCents: meteredItem.unitPriceCents,
};
}
/**
* Recreate billing alert for a subscription. This archives existing alerts and creates
* a new one with the correct threshold based on current pricing and credit balance.
*/
async recreateBillingAlertForSubscription(
subscription: BillingSubscriptionEntity,
periodStart?: Date,
): Promise<void> {
const meteredPricingInfo = await this.getMeteredPricingInfo(
subscription.id,
);
if (!isDefined(meteredPricingInfo)) {
this.logger.warn(
`Cannot create billing alert: metered pricing info not found for subscription ${subscription.id}`,
);
return;
}
const creditBalance =
await this.stripeCreditGrantService.getCustomerCreditBalance(
subscription.stripeCustomerId,
meteredPricingInfo.unitPriceCents,
);
// Use the subscription's current period start if not provided
const effectivePeriodStart = periodStart ?? subscription.currentPeriodStart;
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
subscription.stripeCustomerId,
meteredPricingInfo.tierCap,
creditBalance,
effectivePeriodStart,
);
}
/**
* Get credit balance for a customer in credit units (not monetary).
*/
async getCreditBalance(
stripeCustomerId: string,
unitPriceCents: number,
): Promise<number> {
return this.stripeCreditGrantService.getCustomerCreditBalance(
stripeCustomerId,
unitPriceCents,
);
}
// V2 path — uses productKey === RESOURCE_CREDIT; derives cap from price.metadata.credit_amount
extractResourceCreditPricingInfo(
subscription: BillingSubscriptionEntity,
): ResourceCreditPricingInfo | null {
const resourceCreditItem = subscription.billingSubscriptionItems?.find(
(item) =>
item.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
if (!isDefined(resourceCreditItem)) {
return null;
}
const matchingPrice =
resourceCreditItem.billingProduct?.billingPrices?.find(
(price) => price.stripePriceId === resourceCreditItem.stripePriceId,
);
if (!isDefined(matchingPrice)) {
return null;
}
const tierCap = Number(matchingPrice.metadata?.credit_amount ?? 0);
if (!Number.isFinite(tierCap) || tierCap <= 0) {
return null;
}
return {
tierCap,
unitPriceCents: matchingPrice.unitAmount ?? 0,
};
}
async getResourceCreditRolloverParameters(subscriptionId: string): Promise<{
tierQuantity: number;
unitPriceCents: number;
} | null> {
//TODO : To optimize once evaluateCapBatch is deprecated
const subscription = await this.billingSubscriptionRepository.findOne({
where: { id: subscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
'billingSubscriptionItems.billingProduct.billingPrices',
],
});
if (!isDefined(subscription)) {
return null;
}
const pricingInfo = this.extractResourceCreditPricingInfo(subscription);
if (!isDefined(pricingInfo)) {
return null;
}
return {
tierQuantity: pricingInfo.tierCap,
unitPriceCents: pricingInfo.unitPriceCents,
};
}
}
@@ -0,0 +1,88 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
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';
export type ResourceCreditPricingInfo = {
tierCap: number;
unitPriceCents: number;
};
@Injectable()
export class ResourceCreditService {
protected readonly logger = new Logger(ResourceCreditService.name);
constructor(
@InjectRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
) {}
extractResourceCreditPricingInfo(
subscription: BillingSubscriptionEntity,
): ResourceCreditPricingInfo | null {
const resourceCreditItem = subscription.billingSubscriptionItems?.find(
(item) =>
item.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
if (!isDefined(resourceCreditItem)) {
return null;
}
const matchingPrice =
resourceCreditItem.billingProduct?.billingPrices?.find(
(price) => price.stripePriceId === resourceCreditItem.stripePriceId,
);
if (!isDefined(matchingPrice)) {
return null;
}
const tierCap = Number(matchingPrice.metadata?.credit_amount ?? 0);
if (!Number.isFinite(tierCap) || tierCap <= 0) {
return null;
}
return {
tierCap,
unitPriceCents: matchingPrice.unitAmount ?? 0,
};
}
async getResourceCreditRolloverParameters(subscriptionId: string): Promise<{
tierQuantity: number;
unitPriceCents: number;
} | null> {
const subscription = await this.billingSubscriptionRepository.findOne({
where: { id: subscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
'billingSubscriptionItems.billingProduct.billingPrices',
],
});
if (!isDefined(subscription)) {
return null;
}
const pricingInfo = this.extractResourceCreditPricingInfo(subscription);
if (!isDefined(pricingInfo)) {
return null;
}
return {
tierQuantity: pricingInfo.tierCap,
unitPriceCents: pricingInfo.unitPriceCents,
};
}
}
@@ -3,7 +3,6 @@ import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums
export enum SubscriptionUpdateType {
PLAN = 'PLAN',
METERED_PRICE = 'METERED_PRICE',
RESOURCE_CREDIT_PRICE = 'RESOURCE_CREDIT_PRICE',
SEATS = 'SEATS',
INTERVAL = 'INTERVAL',
@@ -14,10 +13,6 @@ export type SubscriptionUpdate =
type: SubscriptionUpdateType.PLAN;
newPlan: BillingPlanKey;
}
| {
type: SubscriptionUpdateType.METERED_PRICE;
newMeteredPriceId: string;
}
| {
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE;
newResourceCreditPriceId: string;
@@ -32,17 +32,6 @@ describe('computeSubscriptionUpdateOptions', () => {
});
});
it('returns only proration for METERED_PRICE update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.METERED_PRICE,
newMeteredPriceId: 'price_123',
});
expect(result).toEqual({
proration: 'create_prorations',
});
});
it('returns proration and anchor for INTERVAL update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.INTERVAL,
@@ -26,7 +26,7 @@ describe('isStripeValidProductMetadata', () => {
const metadata: Stripe.Metadata = {
planKey: BillingPlanKey.ENTERPRISE,
priceUsageBased: BillingUsageType.METERED,
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
productKey: BillingProductKey.RESOURCE_CREDIT,
randomKey: 'randomValue',
};
@@ -22,10 +22,6 @@ export const computeSubscriptionUpdateOptions = (
plan: subscriptionUpdate.newPlan,
},
};
case SubscriptionUpdateType.METERED_PRICE:
return {
proration: 'create_prorations',
};
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
return {
proration: 'none',
@@ -6,7 +6,6 @@ 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 { BillingProductEntity } from 'src/engine/core-modules/billing/entities/billing-product.entity';
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';
@@ -85,7 +84,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
CleanSuspendedWorkspacesJob,
CleanOnboardingWorkspacesJob,
EmailSenderJob,
EnforceUsageCapJob,
UpdateSubscriptionQuantityJob,
HandleWorkspaceMemberDeletedJob,
CleanWorkspaceDeletionWarningUserVarsJob,
@@ -798,14 +798,6 @@ export class ConfigVariables {
@IsOptional()
BILLING_USAGE_CAP_CLICKHOUSE_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description: 'Enable billing v2 for new workspaces at checkout',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_BILLING_V2_ENABLED_FOR_NEW_WORKSPACES = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Url for the frontend application',