Billing - Migrate from Stripe metering (#20298)

**Overall strategy**
**1. Introduce “Billing V2” behind a workspace flag**
Gate the new model with FeatureFlagKey.IS_BILLING_V2_ENABLED so existing
workspaces stay on the old behavior until they’re migrated or explicitly
on V2.

**2. Replace workflow metered SKUs with a resource-credit product**
Conceptually, billable “workflow execution” usage is not the primary
subscription line item anymore. Add a RESOURCE_CREDIT product (and keep
WORKFLOW_NODE_EXECUTION as deprecated for the transition). Usage and
limits are expressed through credit buckets (e.g. price metadata like
credit_amount), so one product can represent pooled credits instead of a
narrow workflow-only meter.

**3. Migrate subscriptions in two layers**
Schema/catalog: persist extra price metadata (instance upgrade) so the
server knows credit amounts and can match Stripe prices to the new
model.
Per workspace: the registered workspace command
upgrade:2-2:migrate-to-billing-v2 finds subscriptions that still have
WORKFLOW_NODE_EXECUTION, swaps those items to the right RESOURCE_CREDIT
prices (using existing Stripe schedule +
BillingSubscriptionUpdateService stack), then treats the workspace as V2
(flag). Workspaces without that legacy item or without a subscription
are skipped.

**4. Unify subscription lifecycle + usage on the server**

**5. Refresh the product surface in Settings**

Test : 

- [x] Subscribe v1 +  Update subscribe + Migrate
- [x]  Subscribe v2 + Update subscribe
This commit is contained in:
Etienne
2026-05-07 17:42:11 +02:00
committed by GitHub
parent ca58c7f15e
commit 9fc5be1c4c
93 changed files with 2939 additions and 514 deletions
@@ -67,7 +67,7 @@ export class AppBillingController {
APP_BILLING_CHARGE_THROTTLE_TTL_MS,
);
this.appBillingService.emitChargeEvent({
await this.appBillingService.emitChargeEvent({
workspaceId: request.workspace.id,
applicationId: request.application.id,
userWorkspaceId: request.userWorkspaceId,
@@ -5,16 +5,20 @@ import { Module } from '@nestjs/common';
import { AppBillingController } from 'src/engine/core-modules/billing/app-billing/app-billing.controller';
import { AppBillingService } from 'src/engine/core-modules/billing/app-billing/app-billing.service';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@Module({
imports: [
AuthModule,
BillingModule,
ThrottlerModule,
TwentyConfigModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
WorkspaceEventEmitterModule,
],
@@ -3,11 +3,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ChargeDto } from 'src/engine/core-modules/billing/app-billing/dtos/charge.dto';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
// Each operation type has one canonical counting unit — matches how
@@ -27,14 +29,18 @@ const USAGE_UNIT_BY_OPERATION_TYPE: Record<UsageOperationType, UsageUnit> = {
export class AppBillingService {
private readonly logger = new Logger(AppBillingService.name);
constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {}
constructor(
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly billingService: BillingService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
emitChargeEvent(params: {
async emitChargeEvent(params: {
workspaceId: string;
applicationId: string;
userWorkspaceId?: string | null;
charge: ChargeDto;
}): void {
}): Promise<void> {
const { workspaceId, applicationId, userWorkspaceId, charge } = params;
const unit = USAGE_UNIT_BY_OPERATION_TYPE[charge.operationType];
@@ -43,6 +49,18 @@ export class AppBillingService {
`${charge.creditsUsedMicro} micro-credits (${charge.quantity} ${unit}, ${charge.operationType})`,
);
let periodStart: Date | undefined;
if (this.billingService.isBillingEnabled()) {
const {
billingSubscription: { currentPeriodStart },
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
periodStart = currentPeriodStart;
}
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -55,6 +73,7 @@ export class AppBillingService {
resourceId: applicationId,
resourceContext: charge.resourceContext ?? null,
userWorkspaceId: userWorkspaceId ?? null,
periodStart,
},
],
workspaceId,
@@ -101,6 +101,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BillingSubscriptionService,
BillingSubscriptionUpdateService,
BillingSubscriptionItemService,
BillingSubscriptionPhaseService,
BillingPortalWorkspaceService,
BillingService,
BillingUsageService,
@@ -6,15 +6,17 @@ import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
import { 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 { 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';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
@@ -23,13 +25,13 @@ 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 {
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
toDisplayCredits,
} from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
@@ -46,7 +48,7 @@ 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 { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { FeatureFlagKey } from 'twenty-shared/types';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@@ -63,6 +65,7 @@ export class BillingResolver {
private readonly billingService: BillingService,
private readonly billingUsageService: BillingUsageService,
private readonly permissionsService: PermissionsService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Query(() => BillingSessionDTO)
@@ -238,11 +241,23 @@ export class BillingResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
) {
await this.billingSubscriptionUpdateService.changeMeteredPrice(
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
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(
@@ -300,11 +315,18 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
//TODO: To rename to getResourceCreditProductsUsage
async getMeteredProductsUsage(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
const usageData =
await this.billingUsageService.getMeteredProductsUsage(workspace);
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);
return usageData.map((item) => ({
...item,
@@ -1,21 +1,23 @@
import { isDefined } from 'twenty-shared/utils';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
import { isNumber } from 'class-validator';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { type 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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import {
type LicensedBillingSubscriptionItem,
type MeteredBillingSubscriptionItem,
} from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
import { type BillingSubscriptionWithSubscriptionItems } from 'src/engine/core-modules/billing/types/billing-subscription-with-subscription-items';
import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
const assertIsMeteredTiersSchemaOrThrow = (
tiers: BillingPriceEntity['tiers'] | undefined | null,
@@ -148,6 +150,45 @@ const assertIsSubscription = (
return;
};
// V2 validators — do not throw for V1 items; only used on V2 code paths
const isLicensedResourceCreditItem = (
subscriptionItem: BillingSubscriptionItemEntity,
): boolean => {
return (
subscriptionItem.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT
);
};
const assertIsLicensedResourceCreditPrice = (
price: BillingPriceEntity,
): void => {
if (
price.billingProduct?.metadata?.productKey !==
BillingProductKey.RESOURCE_CREDIT
) {
throw new BillingException(
'Price is not a RESOURCE_CREDIT licensed price',
BillingExceptionCode.BILLING_PRICE_INVALID,
);
}
const creditAmount = price.metadata?.credit_amount;
if (!isDefined(creditAmount) || !isNumber(Number(creditAmount))) {
throw new BillingException(
'RESOURCE_CREDIT price must have a credit_amount in metadata',
BillingExceptionCode.BILLING_PRICE_INVALID,
);
}
};
const getCapFromCreditMetadata = (price: BillingPriceEntity): number => {
assertIsLicensedResourceCreditPrice(price);
return Number(price.metadata?.credit_amount);
};
export const billingValidator: {
assertIsMeteredTiersSchemaOrThrow: typeof assertIsMeteredTiersSchemaOrThrow;
isMeteredTiersSchema: typeof isMeteredTiersSchema;
@@ -156,6 +197,9 @@ export const billingValidator: {
assertIsMeteredPrice: typeof assertIsMeteredPrice;
assertIsSubscription: typeof assertIsSubscription;
isMeteredPrice: typeof isMeteredPrice;
assertIsLicensedResourceCreditPrice: typeof assertIsLicensedResourceCreditPrice;
isLicensedResourceCreditItem: typeof isLicensedResourceCreditItem;
getCapFromCreditMetadata: typeof getCapFromCreditMetadata;
} = {
assertIsMeteredTiersSchemaOrThrow,
isMeteredTiersSchema,
@@ -164,4 +208,7 @@ export const billingValidator: {
assertIsMeteredPrice,
assertIsSubscription,
isMeteredPrice,
assertIsLicensedResourceCreditPrice,
isLicensedResourceCreditItem,
getCapFromCreditMetadata,
};
@@ -11,6 +11,7 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
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';
@@ -22,7 +23,15 @@ describe('EnforceUsageCapJob', () => {
let billingSubscriptionItemRepository: jest.Mocked<{
update: jest.Mock;
}>;
let billingUsageCapService: jest.Mocked<BillingUsageCapService>;
let billingUsageCapService: jest.Mocked<
Pick<
BillingUsageCapService,
| 'isClickHouseEnabled'
| 'getBatchPeriodCreditsUsed'
| 'evaluateCapBatch'
| 'evaluateCapBatchV2'
>
>;
let twentyConfigService: jest.Mocked<TwentyConfigService>;
const buildSubscription = ({
@@ -87,12 +96,19 @@ describe('EnforceUsageCapJob', () => {
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();
@@ -4,6 +4,7 @@ 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';
@@ -14,6 +15,7 @@ import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing
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';
@@ -35,6 +37,7 @@ export class EnforceUsageCapJob {
private readonly billingProductRepository: Repository<BillingProductEntity>,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Process(EnforceUsageCapJob.name)
@@ -155,11 +158,35 @@ export class EnforceUsageCapJob {
}
}
const evaluations = this.billingUsageCapService.evaluateCapBatch(
batch,
// 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[] = [];
@@ -177,10 +204,15 @@ export class EnforceUsageCapJob {
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 === BillingProductKey.WORKFLOW_NODE_EXECUTION,
?.productKey === targetProductKey,
);
if (!meteredItem) {
@@ -14,7 +14,10 @@ export class BillingPlanDTO {
planKey: BillingPlanKey;
@Field(() => [BillingLicensedProduct])
licensedProducts: BillingLicensedProduct[];
baseProducts: BillingLicensedProduct[];
@Field(() => [BillingLicensedProduct])
resourceCreditProducts: BillingLicensedProduct[];
@Field(() => [BillingMeteredProduct])
meteredProducts: BillingMeteredProduct[];
@@ -18,4 +18,7 @@ export class BillingPriceLicensedDTO {
@Field(() => BillingUsageType)
priceUsageType: BillingUsageType.LICENSED;
@Field(() => Number, { nullable: true })
creditAmount: number | null;
}
@@ -22,6 +22,7 @@ import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/b
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { BillingPriceMetadata } from 'src/engine/core-modules/billing/types/billing-price-metadata.type';
@Entity({ name: 'billingPrice', schema: 'core' })
export class BillingPriceEntity {
@@ -94,6 +95,9 @@ export class BillingPriceEntity {
@Column({ nullable: true, type: 'text' })
stripeMeterId: string | null;
@Column({ nullable: false, type: 'jsonb', default: {} })
metadata: BillingPriceMetadata;
@Field(() => BillingUsageType)
@Column({
type: 'enum',
@@ -4,6 +4,8 @@ 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',
}
@@ -3,9 +3,11 @@
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';
@@ -16,6 +18,7 @@ export class BillingUsageEventListener {
constructor(
private readonly billingUsageService: BillingUsageService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
) {}
@OnCustomBatchEvent(USAGE_RECORDED)
@@ -38,6 +41,16 @@ export class BillingUsageEventListener {
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,
@@ -7,6 +7,7 @@ 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';
@@ -35,6 +36,12 @@ describe('BillingCreditRolloverService', () => {
sumMeterEvents: jest.fn(),
},
},
{
provide: BillingUsageService,
useValue: {
getCurrentPeriodCreditsUsed: jest.fn().mockResolvedValue(0),
},
},
{
provide: getRepositoryToken(BillingCustomerEntity),
useValue: {
@@ -17,9 +17,11 @@ import { BillingSubscriptionUpdateService } from 'src/engine/core-modules/billin
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';
import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
arrangeBillingPriceRepositoryFindOneOrFail,
@@ -66,6 +68,20 @@ describe('BillingSubscriptionUpdateService', () => {
module = await Test.createTestingModule({
providers: [
BillingSubscriptionUpdateService,
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
provide: StripeInvoiceService,
useValue: {
createImmediateUpgradeInvoice: jest
.fn()
.mockResolvedValue(undefined),
},
},
{
provide: BillingSubscriptionService,
useValue: {
@@ -100,29 +116,59 @@ describe('BillingSubscriptionUpdateService', () => {
provide: BillingSubscriptionPhaseService,
useValue: {
toPhaseUpdateParams: jest.fn(),
buildPhaseUpdateParams: jest
.fn()
.mockImplementation(
async ({
licensedStripePriceId,
seats,
meteredStripePriceId,
startDate,
endDate,
}) => ({
buildPhaseUpdateParams: jest.fn().mockImplementation(
async ({
toUpdatePrices,
startDate,
endDate,
isV2,
}: {
toUpdatePrices: {
licensedPriceId: string;
seats: number;
meteredPriceId?: string;
resourceCreditPriceId?: string;
};
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
isV2: boolean;
}) => {
if (isV2) {
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{
price: toUpdatePrices.resourceCreditPriceId,
quantity: 1,
},
],
};
}
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{ price: licensedStripePriceId, quantity: seats },
{ price: meteredStripePriceId },
{
price: toUpdatePrices.licensedPriceId,
quantity: toUpdatePrices.seats,
},
{ price: toUpdatePrices.meteredPriceId },
],
billing_thresholds: {
amount_gte: 1000,
reset_billing_cycle_anchor: false,
},
}),
),
};
},
),
isSamePhaseSignature: jest.fn().mockResolvedValue(false),
},
},
@@ -251,7 +297,7 @@ describe('BillingSubscriptionUpdateService', () => {
},
{ id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID },
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
metadata: { plan: BillingPlanKey.ENTERPRISE },
billing_thresholds: {
amount_gte: 1000,
@@ -377,7 +423,7 @@ describe('BillingSubscriptionUpdateService', () => {
},
{ id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID },
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
metadata: { plan: BillingPlanKey.ENTERPRISE },
billing_thresholds: {
amount_gte: 1000,
@@ -9,9 +9,8 @@ import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/e
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 { 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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
describe('BillingUsageCapService', () => {
let service: BillingUsageCapService;
@@ -55,18 +54,9 @@ describe('BillingUsageCapService', () => {
},
},
{
provide: CacheStorageNamespace.EngineBillingUsage,
provide: FeatureFlagService,
useValue: {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
incrBy: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingSubscriptionEntity),
useValue: {
findOne: jest.fn(),
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
@@ -76,12 +66,6 @@ describe('BillingUsageCapService', () => {
update: jest.fn(),
},
},
{
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn(),
},
},
],
}).compile();
@@ -100,7 +100,7 @@ export const arrangeBillingSubscriptionRepositoryFindOneOrFail = (
export const arrangeBillingPriceRepositoryFindOneOrFail = (
billingPriceRepository: jest.Mocked<Repository<BillingPriceEntity>>,
priceIdToPriceMap: Record<string, BillingPriceEntity | BillingMeterPrice>,
) =>
) => {
jest
.spyOn(billingPriceRepository, 'findOneOrFail')
.mockImplementation(async (criteria: unknown) => {
@@ -114,6 +114,42 @@ export const arrangeBillingPriceRepositoryFindOneOrFail = (
return {} as BillingPriceEntity;
});
jest
.spyOn(billingPriceRepository, 'find')
.mockImplementation(async (criteria?: unknown) => {
const where = (criteria as { where?: { stripePriceId?: unknown } })
?.where;
const stripePriceIdCondition = where?.stripePriceId;
const resolveStripePriceIds = (cond: unknown): string[] => {
if (typeof cond === 'string') {
return [cond];
}
if (
cond &&
typeof cond === 'object' &&
'value' in cond &&
(cond as { value: unknown }).value !== undefined
) {
const value = (cond as { value: string | string[] }).value;
return Array.isArray(value) ? value : [value];
}
return [];
};
const stripePriceIds = resolveStripePriceIds(stripePriceIdCondition);
return stripePriceIds
.map((stripePriceId) => priceIdToPriceMap[stripePriceId])
.filter(
(entity): entity is BillingPriceEntity =>
entity !== null && entity !== undefined,
);
});
};
export const arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule =
(
stripeSubscriptionScheduleService: jest.Mocked<StripeSubscriptionScheduleService>,
@@ -6,6 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
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';
@@ -14,6 +15,7 @@ export class BillingCreditRolloverService {
constructor(
private readonly stripeCreditGrantService: StripeCreditGrantService,
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
private readonly billingUsageService: BillingUsageService,
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
) {}
@@ -75,6 +77,33 @@ export class BillingCreditRolloverService {
await this.refreshCreditBalance(stripeCustomerId, unitPriceCents);
}
// V2 path — reads usedCredits from ClickHouse; writes rollover directly to creditBalanceMicro
async processRolloverOnPeriodTransitionV2({
workspaceId,
stripeCustomerId,
tierQuantity,
previousPeriodStart,
}: {
workspaceId: string;
stripeCustomerId: string;
tierQuantity: number;
previousPeriodStart: Date;
}): Promise<void> {
const usedCredits =
await this.billingUsageService.getCurrentPeriodCreditsUsed(
workspaceId,
previousPeriodStart,
);
const unusedCredits = Math.max(0, tierQuantity - usedCredits);
const rolloverAmount = Math.min(unusedCredits, tierQuantity);
await this.billingCustomerRepository.update(
{ stripeCustomerId },
{ creditBalanceMicro: rolloverAmount },
);
}
private async refreshCreditBalance(
stripeCustomerId: string,
unitPriceCents: number,
@@ -3,8 +3,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { JsonContains, Repository } from 'typeorm';
import { findOrThrow } from 'twenty-shared/utils';
import { JsonContains, Repository } from 'typeorm';
import {
BillingException,
@@ -82,15 +82,20 @@ export class BillingPlanService {
(product) =>
product.metadata.priceUsageBased === BillingUsageType.METERED,
);
const licensedProducts = planProducts.filter(
const baseProducts = planProducts.filter(
(product) =>
product.metadata.priceUsageBased === BillingUsageType.LICENSED,
product.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
const resourceCreditProducts = planProducts.filter(
(product) =>
product.metadata.productKey === BillingProductKey.RESOURCE_CREDIT,
);
return {
planKey,
meteredProducts,
licensedProducts,
baseProducts,
resourceCreditProducts,
};
});
}
@@ -105,7 +110,12 @@ export class BillingPlanService {
(price) => price.stripePriceId === stripePriceId,
),
) ||
plan.licensedProducts.some((product) =>
plan.baseProducts.some((product) =>
product.billingPrices.some(
(price) => price.stripePriceId === stripePriceId,
),
) ||
plan.resourceCreditProducts.some((product) =>
product.billingPrices.some(
(price) => price.stripePriceId === stripePriceId,
),
@@ -130,21 +140,24 @@ export class BillingPlanService {
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
);
}
const { meteredProducts, licensedProducts } = plan;
const { meteredProducts, baseProducts, resourceCreditProducts } = plan;
const filterPricesByInterval = (product: BillingProductEntity) =>
product.billingPrices.filter((price) => price.interval === interval);
const meteredProductsPrices = meteredProducts.flatMap(
const meteredProductPrices = meteredProducts.flatMap(
filterPricesByInterval,
);
const licensedProductsPrices = licensedProducts.flatMap(
const baseProductPrices = baseProducts.flatMap(filterPricesByInterval);
const resourceCreditProductPrices = resourceCreditProducts.flatMap(
filterPricesByInterval,
);
return {
meteredProductsPrices,
licensedProductsPrices,
meteredProductPrices,
baseProductPrices,
resourceCreditProductPrices,
};
}
}
@@ -29,8 +29,11 @@ import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/bill
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 {
@@ -40,12 +43,14 @@ 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({
@@ -127,14 +132,9 @@ export class BillingPortalWorkspaceService {
!isDefined(customer) || customer.billingSubscriptions.length === 0,
});
const createdBillingSubscription =
await this.billingSubscriptionService.syncSubscriptionToDatabase(
workspace.id,
stripeSubscription.id,
);
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
createdBillingSubscription.id,
await this.billingSubscriptionService.syncSubscriptionToDatabase(
workspace.id,
stripeSubscription.id,
);
return successUrl;
@@ -168,10 +168,12 @@ export class BillingPortalWorkspaceService {
relations: ['billingSubscriptions'],
});
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
});
const stripeSubscriptionLineItems =
await this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId: workspace.id,
});
return {
successUrl,
@@ -265,7 +267,7 @@ export class BillingPortalWorkspaceService {
billingPricesPerPlan: BillingGetPricesPerPlanResult,
): BillingMeterPrice {
const defaultMeteredProductPrice =
billingPricesPerPlan.meteredProductsPrices.reduce(
billingPricesPerPlan.meteredProductPrices.reduce(
(result, billingPrice) => {
if (!result) {
return billingPrice as BillingMeterPrice;
@@ -293,20 +295,50 @@ export class BillingPortalWorkspaceService {
return defaultMeteredProductPrice;
}
private getStripeSubscriptionLineItems({
// V2 path — finds the lowest credit_amount RESOURCE_CREDIT licensed price as default
private getDefaultResourceCreditPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
) {
const resourceCreditPrices =
billingPricesPerPlan.resourceCreditProductPrices;
if (!isDefined(resourceCreditPrices) || resourceCreditPrices.length === 0) {
throw new BillingException(
'Missing Default RESOURCE_CREDIT price',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return resourceCreditPrices.reduce((lowest, price) => {
const amount = Number(price.metadata?.credit_amount ?? 0);
const lowestAmount = Number(lowest.metadata?.credit_amount ?? 0);
return amount < lowestAmount ? price : lowest;
});
}
private async getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
workspaceId,
}: {
quantity: number;
billingPricesPerPlan: BillingGetPricesPerPlanResult;
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
const defaultMeteredProductPrice =
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
workspaceId: string;
}): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
const defaultLicensedProductPrice = findOrThrow(
billingPricesPerPlan.licensedProductsPrices,
(licensedProductsPrice) =>
licensedProductsPrice.billingProduct?.metadata.productKey ===
const isBillingV2EnabledForNewWorkspaces = this.twentyConfigService.get(
'IS_BILLING_V2_ENABLED_FOR_NEW_WORKSPACES',
);
const defaultBaseProductPrice = findOrThrow(
billingPricesPerPlan.baseProductPrices,
(baseProductPrice) =>
baseProductPrice.billingProduct?.metadata.productKey ===
BillingProductKey.BASE_PRODUCT,
new BillingException(
`Base product not found`,
@@ -314,9 +346,28 @@ 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);
return [
{
price: defaultLicensedProductPrice.stripePriceId,
price: defaultBaseProductPrice.stripePriceId,
quantity,
},
{
@@ -12,6 +12,7 @@ import {
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
@@ -129,4 +130,66 @@ export class BillingPriceService {
) as BillingMeterPrice[]
).sort((a, b) => a.tiers[0].up_to - b.tiers[0].up_to);
}
// V2 counterpart of findEquivalentMeteredPrice.
// Finds a RESOURCE_CREDIT price matching the target interval and plan,
// with the largest credit_amount that does not exceed the reference credit_amount.
async findEquivalentResourceCreditPrice({
targetInterval,
targetPlanKey,
hasSameInterval,
hasSamePlanKey,
referencePrice,
}: {
targetInterval: SubscriptionInterval;
targetPlanKey: BillingPlanKey;
hasSameInterval: boolean;
hasSamePlanKey: boolean;
referencePrice: BillingPriceEntity;
}): Promise<BillingPriceEntity> {
if (hasSameInterval && hasSamePlanKey) {
return referencePrice;
}
const catalog = await this.billingProductService.getProductPrices({
interval: targetInterval,
planKey: targetPlanKey,
});
const referenceCreditAmount = Number(
referencePrice.metadata?.credit_amount,
);
const scaledAmount =
!hasSameInterval && targetInterval === SubscriptionInterval.Year
? referenceCreditAmount * 12
: !hasSameInterval && targetInterval === SubscriptionInterval.Month
? referenceCreditAmount / 12
: referenceCreditAmount;
const resourceCreditCandidates = catalog
.filter(
(p) =>
p.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
)
.sort(
(a, b) =>
Number(a.metadata?.credit_amount ?? 0) -
Number(b.metadata?.credit_amount ?? 0),
);
if (!resourceCreditCandidates.length) {
throw new BillingException(
'No RESOURCE_CREDIT price candidates found',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return (
resourceCreditCandidates
.filter((p) => Number(p.metadata?.credit_amount ?? 0) <= scaledAmount)
.pop() ?? resourceCreditCandidates[0]
);
}
}
@@ -59,6 +59,10 @@ export class BillingProductService {
);
}
return [...plan.licensedProducts, ...plan.meteredProducts];
return [
...plan.baseProducts,
...plan.resourceCreditProducts,
...plan.meteredProducts,
];
}
}
@@ -1,17 +1,20 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Raw, Repository } from 'typeorm';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { differenceInDays } from 'date-fns';
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';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { isDefined } from 'twenty-shared/utils';
@Injectable()
export class BillingSubscriptionItemService {
@@ -58,6 +61,57 @@ export class BillingSubscriptionItemService {
);
}
async getResourceCreditSubscriptionItemDetails(
subscription: BillingSubscriptionEntity,
): Promise<{
stripeSubscriptionItemId: string;
productKey: BillingProductKey;
creditAmount: number;
freeTrialQuantity: number;
unitPriceCents: number;
} | null> {
const item = await this.billingSubscriptionItemRepository.findOne({
where: {
billingSubscriptionId: subscription.id,
billingProduct: {
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
metadata: JSON.stringify({
productKey: BillingProductKey.RESOURCE_CREDIT,
}),
}),
},
},
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
if (!item) {
return null;
}
const price = this.findMatchingPrice(item);
const trialDuration =
isDefined(subscription.trialEnd) && isDefined(subscription.trialStart)
? differenceInDays(subscription.trialEnd, subscription.trialStart)
: 0;
const trialWithCreditCardDuration = this.twentyConfigService.get(
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
);
return {
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: BillingProductKey.RESOURCE_CREDIT,
creditAmount: Number(price.metadata?.credit_amount ?? 0),
freeTrialQuantity: this.twentyConfigService.get(
trialDuration === trialWithCreditCardDuration
? 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD'
: 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
),
unitPriceCents: price.unitAmount ?? 0,
};
}
private findMatchingPrice(
item: BillingSubscriptionItemEntity,
): BillingPriceEntity {
@@ -16,6 +16,7 @@ import { type BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-module
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
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 { SubscriptionStripePrices } from 'src/engine/core-modules/billing/services/billing-subscription-update.service';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
@Injectable()
@@ -83,31 +84,51 @@ export class BillingSubscriptionPhaseService {
}
async buildPhaseUpdateParams({
licensedStripePriceId,
seats,
meteredStripePriceId,
toUpdatePrices,
startDate,
endDate,
isV2,
}: {
licensedStripePriceId: string;
seats: number;
meteredStripePriceId: string;
toUpdatePrices: SubscriptionStripePrices;
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
isV2: boolean;
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{ price: licensedStripePriceId, quantity: seats },
{ price: meteredStripePriceId },
],
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meteredStripePriceId,
),
};
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,
),
};
}
}
getLicensedPriceIdAndQuantityFromPhaseUpdateParams(
@@ -159,4 +180,76 @@ export class BillingSubscriptionPhaseService {
return false;
}
}
// Billing V2: emits { price, quantity: 1 } for the resource credit price; no billing_thresholds
async buildResourceCreditPhaseUpdateParams({
basePlanStripePriceId,
seats,
resourceCreditStripePriceId,
startDate,
endDate,
}: {
basePlanStripePriceId: string;
seats: number;
resourceCreditStripePriceId: string;
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
return {
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{ price: basePlanStripePriceId, quantity: seats },
{ price: resourceCreditStripePriceId, quantity: 1 },
],
};
}
// Billing V2: compares resource credit Stripe price id between phases
async isSameResourceCreditPhaseSignature(
a: Stripe.SubscriptionScheduleUpdateParams.Phase,
b: Stripe.SubscriptionScheduleUpdateParams.Phase,
): Promise<boolean> {
try {
const phaseALicensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(a);
const phaseBLicensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(b);
const phaseAResourceCreditPriceId =
this.getResourceCreditPriceIdFromPhaseUpdateParams(a);
const phaseBResourceCreditPriceId =
this.getResourceCreditPriceIdFromPhaseUpdateParams(b);
return (
phaseALicensedPriceIdAndQuantity.price ===
phaseBLicensedPriceIdAndQuantity.price &&
phaseALicensedPriceIdAndQuantity.quantity ===
phaseBLicensedPriceIdAndQuantity.quantity &&
phaseAResourceCreditPriceId === phaseBResourceCreditPriceId
);
} catch {
return false;
}
}
// Billing V2 counterpart of getMeteredPriceIdFromPhaseUpdateParams (resource credit has quantity: 1)
getResourceCreditPriceIdFromPhaseUpdateParams(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
const items = phase.items ?? [];
const licensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(phase);
const resourceCreditItem = items.find(
(item) =>
item.price !== licensedPriceIdAndQuantity.price && item.quantity === 1,
);
if (!resourceCreditItem?.price) {
throw new Error('Resource credit item not found in V2 phase params');
}
return resourceCreditItem.price;
}
}
@@ -9,7 +9,7 @@ import {
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { In, type Repository } from 'typeorm';
import type Stripe from 'stripe';
@@ -26,6 +26,7 @@ import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing
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';
import {
@@ -33,15 +34,20 @@ import {
SubscriptionUpdateType,
} from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
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 { getSubscriptionPricesFromSchedulePhase } from 'src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.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';
type SubscriptionStripePrices = {
export type SubscriptionStripePrices = {
licensedPriceId: string;
seats: number;
meteredPriceId: string;
meteredPriceId: string | undefined;
resourceCreditPriceId: string | undefined;
};
@Injectable()
@@ -50,6 +56,7 @@ export class BillingSubscriptionUpdateService {
constructor(
private readonly stripeSubscriptionService: StripeSubscriptionService,
private readonly stripeInvoiceService: StripeInvoiceService,
private readonly billingPriceService: BillingPriceService,
private readonly billingProductService: BillingProductService,
@InjectRepository(BillingPriceEntity)
@@ -63,8 +70,16 @@ export class BillingSubscriptionUpdateService {
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,
@@ -83,6 +98,10 @@ export class BillingSubscriptionUpdateService {
}
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 },
@@ -97,6 +116,40 @@ export class BillingSubscriptionUpdateService {
});
}
async changeResourceCreditPrice(
workspaceId: string,
resourceCreditPriceId: string,
): Promise<void> {
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId },
);
const subscriptionUpdate = {
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE,
newResourceCreditPriceId: resourceCreditPriceId,
} as const;
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
}
async cancelSwitchResourceCreditPrice(
workspace: WorkspaceEntity,
): Promise<void> {
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
const currentResourceCreditPrice =
getCurrentResourceCreditSubscriptionItemOrThrow(billingSubscription);
const subscriptionUpdate = {
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE,
newResourceCreditPriceId: currentResourceCreditPrice.stripePriceId,
} as const;
await this.updateSubscription(billingSubscription.id, subscriptionUpdate);
}
async cancelSwitchPlan(workspaceId: string) {
const billingSubscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
@@ -189,18 +242,27 @@ export class BillingSubscriptionUpdateService {
},
);
const licensedItem =
getCurrentLicensedBillingSubscriptionItemOrThrow(subscription);
const meteredItem =
getCurrentMeteredBillingSubscriptionItemOrThrow(subscription);
const isV2 = await this.isV2(subscription.workspaceId);
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,
meteredPriceId: meteredItem?.stripePriceId,
resourceCreditPriceId: resourceCreditItem?.stripePriceId,
seats: licensedItem.quantity,
},
isV2,
);
const { schedule, currentPhase, nextPhase } =
@@ -232,14 +294,19 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
} else {
assertIsDefinedOrThrow(nextPhase);
assertIsDefinedOrThrow(currentPhase);
const nextPhasePrices =
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
getSubscriptionPricesFromSchedulePhase(nextPhase),
nextPhasePrices,
isV2,
);
await this.runSubscriptionScheduleUpdate({
@@ -253,20 +320,37 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
}
} else {
const subscriptionOptions =
computeSubscriptionUpdateOptions(subscriptionUpdate);
if (
subscriptionUpdate.type === SubscriptionUpdateType.RESOURCE_CREDIT_PRICE
) {
assertIsDefinedOrThrow(resourceCreditItem);
await this.createResourceCreditUpgradeInvoice({
subscription,
currentResourceCreditPriceId: resourceCreditItem.stripePriceId,
newResourceCreditPriceId: subscriptionUpdate.newResourceCreditPriceId,
});
}
await this.runSubscriptionUpdate({
stripeSubscriptionId: subscription.stripeSubscriptionId,
licensedStripeItemId: licensedItem.stripeSubscriptionItemId,
meteredStripeItemId: meteredItem.stripeSubscriptionItemId,
meteredStripeItemId: meteredItem?.stripeSubscriptionItemId,
resourceCreditStripeItemId:
resourceCreditItem?.stripeSubscriptionItemId,
licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId,
meteredStripePriceId: toUpdateCurrentPrices.meteredPriceId,
meteredStripePriceId: toUpdateCurrentPrices?.meteredPriceId,
resourceCreditStripePriceId:
toUpdateCurrentPrices?.resourceCreditPriceId,
seats: toUpdateCurrentPrices.seats,
...subscriptionOptions,
isV2,
});
if (subscriptionUpdate.type !== SubscriptionUpdateType.SEATS) {
@@ -274,23 +358,6 @@ export class BillingSubscriptionUpdateService {
{ stripeSubscriptionId: subscription.stripeSubscriptionId },
{ hasReachedCurrentPeriodCap: false },
);
const meteredPricingInfo =
await this.meteredCreditService.getMeteredPricingInfoFromPriceId(
toUpdateCurrentPrices.meteredPriceId,
);
const creditBalance = await this.meteredCreditService.getCreditBalance(
subscription.stripeCustomerId,
meteredPricingInfo.unitPriceCents,
);
await this.stripeBillingAlertService.createUsageThresholdAlertForCustomerMeter(
subscription.stripeCustomerId,
meteredPricingInfo.tierCap,
creditBalance,
subscription.currentPeriodStart,
);
}
if (isDefined(nextPhase)) {
@@ -303,10 +370,11 @@ export class BillingSubscriptionUpdateService {
assertIsDefinedOrThrow(refreshedCurrentPhase);
const nextPhasePrices =
getSubscriptionPricesFromSchedulePhase(nextPhase);
await this.getSubscriptionPricesFromSchedulePhaseV2(nextPhase, isV2);
const toUpdateNextPrices = await this.computeSubscriptionPricesUpdate(
subscriptionUpdate,
nextPhasePrices,
isV2,
);
await this.runSubscriptionScheduleUpdate({
@@ -320,6 +388,7 @@ export class BillingSubscriptionUpdateService {
subscriptionCurrentPeriodEnd: Math.floor(
subscription.currentPeriodEnd.getTime() / 1000,
),
isV2,
});
}
}
@@ -330,61 +399,196 @@ export class BillingSubscriptionUpdateService {
);
}
private async createResourceCreditUpgradeInvoice({
subscription,
currentResourceCreditPriceId,
newResourceCreditPriceId,
}: {
subscription: BillingSubscriptionEntity;
currentResourceCreditPriceId: string;
newResourceCreditPriceId: string;
}): Promise<void> {
const prices = await this.billingPriceRepository.find({
where: {
stripePriceId: In([
currentResourceCreditPriceId,
newResourceCreditPriceId,
]),
},
});
const currentPrice = prices.find(
(price) => price.stripePriceId === currentResourceCreditPriceId,
);
const newPrice = prices.find(
(price) => price.stripePriceId === newResourceCreditPriceId,
);
assertIsDefinedOrThrow(currentPrice);
assertIsDefinedOrThrow(newPrice);
const diffInCents =
Number(newPrice.unitAmount) - Number(currentPrice.unitAmount);
if (diffInCents > 0) {
await this.stripeInvoiceService.createImmediateUpgradeInvoice({
stripeCustomerId: subscription.stripeCustomerId,
stripeSubscriptionId: subscription.stripeSubscriptionId,
diffAmountInCents: diffInCents,
description: `Resource usage - Upgrade resource credit price from $${Number(currentPrice.unitAmount) / 100} to $${Number(newPrice.unitAmount) / 100}`,
currency: newPrice.currency,
});
}
}
private async getSubscriptionPricesFromSchedulePhaseV2(
phase: Stripe.SubscriptionSchedule.Phase,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const licensedItemPriceIds = phase.items
.filter((item) => item.quantity != null)
.map((item) => normalizePriceRef(item.price));
const licensedItemPrices = await this.billingPriceRepository.find({
where: { stripePriceId: In(licensedItemPriceIds) },
relations: ['billingProduct'],
});
const basePlanPrice = licensedItemPrices.find(
(price) =>
price.billingProduct?.metadata?.productKey ===
BillingProductKey.BASE_PRODUCT,
);
assertIsDefinedOrThrow(basePlanPrice);
const basePlanPhaseItem = findOrThrow(
phase.items,
(item) => normalizePriceRef(item.price) === basePlanPrice.stripePriceId,
);
assertIsDefinedOrThrow(basePlanPhaseItem.quantity);
if (isV2) {
const resourceCreditPrice = licensedItemPrices.find(
(price) =>
price.billingProduct?.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
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,
};
}
}
private async runSubscriptionUpdate({
stripeSubscriptionId,
licensedStripeItemId,
meteredStripeItemId,
resourceCreditStripeItemId,
licensedStripePriceId,
meteredStripePriceId,
resourceCreditStripePriceId,
seats,
anchor,
proration,
metadata,
isV2,
}: {
stripeSubscriptionId: string;
licensedStripeItemId: string;
meteredStripeItemId: string;
meteredStripeItemId: string | undefined;
resourceCreditStripeItemId: string | undefined;
licensedStripePriceId: string;
meteredStripePriceId: string;
meteredStripePriceId: string | undefined;
resourceCreditStripePriceId: string | undefined;
seats: number;
anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor;
proration?: Stripe.SubscriptionUpdateParams.ProrationBehavior;
metadata?: Record<string, string>;
isV2: boolean;
}) {
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,
),
},
);
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,
),
},
);
}
}
private async runSubscriptionScheduleUpdate({
async runSubscriptionScheduleUpdate({
stripeScheduleId,
toUpdateNextPrices,
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,
@@ -394,21 +598,19 @@ export class BillingSubscriptionUpdateService {
if (isDefined(toUpdateCurrentPrices)) {
toUpdateCurrentPhase =
await this.billingSubscriptionPhaseService.buildPhaseUpdateParams({
licensedStripePriceId: toUpdateCurrentPrices.licensedPriceId,
seats: toUpdateCurrentPrices.seats,
meteredStripePriceId: toUpdateCurrentPrices.meteredPriceId,
toUpdatePrices: toUpdateCurrentPrices,
endDate: subscriptionCurrentPeriodEnd,
startDate: currentPhase.start_date,
isV2,
});
}
const toUpdateNextPhase =
await this.billingSubscriptionPhaseService.buildPhaseUpdateParams({
licensedStripePriceId: toUpdateNextPrices.licensedPriceId,
seats: toUpdateNextPrices.seats,
meteredStripePriceId: toUpdateNextPrices.meteredPriceId,
toUpdatePrices: toUpdateNextPrices,
startDate: subscriptionCurrentPeriodEnd,
endDate: undefined,
isV2,
});
if (
@@ -475,6 +677,44 @@ export class BillingSubscriptionUpdateService {
return isDowngrade;
}
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE: {
const currentResourceCreditPriceId =
subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct?.metadata.productKey ===
BillingProductKey.RESOURCE_CREDIT,
)?.stripePriceId;
assertIsDefinedOrThrow(currentResourceCreditPriceId);
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentResourceCreditPriceId },
relations: ['billingProduct'],
});
const newResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: update.newResourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
billingValidator.assertIsLicensedResourceCreditPrice(
newResourceCreditPrice,
);
const currentResourceCreditCap = Number(
currentResourceCreditPrice.metadata?.credit_amount,
);
const newResourceCreditCap = Number(
newResourceCreditPrice.metadata?.credit_amount,
);
const isDowngrade = currentResourceCreditCap > newResourceCreditCap;
return isDowngrade;
}
case SubscriptionUpdateType.SEATS:
return false;
case SubscriptionUpdateType.INTERVAL: {
@@ -497,12 +737,14 @@ 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(
@@ -518,6 +760,12 @@ export class BillingSubscriptionUpdateService {
return await this.computeSubscriptionPricesUpdateByInterval(
update.newInterval,
currentPrices,
isV2,
);
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
return await this.computeSubscriptionPricesUpdateByResourceCreditPrice(
update.newResourceCreditPriceId,
currentPrices,
);
}
}
@@ -578,10 +826,60 @@ export class BillingSubscriptionUpdateService {
meteredPriceId: newEquivalentMeteredPrice.stripePriceId,
};
}
private async computeSubscriptionPricesUpdateByResourceCreditPrice(
newResourceCreditPriceId: 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 newResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: newResourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsLicensedResourceCreditPrice(
newResourceCreditPrice,
);
const newInterval = newResourceCreditPrice.interval;
const newPlanKey = newResourceCreditPrice.billingProduct?.metadata.planKey;
if (newInterval === currentInterval && currentPlanKey === newPlanKey) {
return {
...currentPrices,
resourceCreditPriceId: newResourceCreditPriceId,
};
}
const newEquivalentResourceCreditPrice =
await this.billingPriceService.findEquivalentResourceCreditPrice({
referencePrice: newResourceCreditPrice,
targetInterval: currentInterval,
targetPlanKey: currentPlanKey,
hasSameInterval: newInterval === currentInterval,
hasSamePlanKey: currentPlanKey === newPlanKey,
});
return {
...currentPrices,
resourceCreditPriceId: newEquivalentResourceCreditPrice.stripePriceId,
};
}
private async computeSubscriptionPricesUpdateByPlan(
newPlan: BillingPlanKey,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
@@ -611,34 +909,61 @@ export class BillingSubscriptionUpdateService {
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
const currentMeteredPrice = await this.billingPriceRepository.findOneOrFail(
{
where: { stripePriceId: currentPrices.meteredPriceId },
relations: ['billingProduct'],
},
);
if (isV2) {
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
const targetMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: currentMeteredPrice,
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,
meteredPriceId: targetMeteredPrice.stripePriceId,
};
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,
};
}
}
private async computeSubscriptionPricesUpdateByInterval(
newInterval: SubscriptionInterval,
currentPrices: SubscriptionStripePrices,
isV2: boolean,
): Promise<SubscriptionStripePrices> {
const currentLicensedPrice =
await this.billingPriceRepository.findOneOrFail({
@@ -668,28 +993,54 @@ export class BillingSubscriptionUpdateService {
billingProduct?.metadata.productKey === BillingProductKey.BASE_PRODUCT,
);
const currentMeteredPrice = await this.billingPriceRepository.findOneOrFail(
{
where: { stripePriceId: currentPrices.meteredPriceId },
relations: ['billingProduct'],
},
);
if (isV2) {
const currentResourceCreditPrice =
await this.billingPriceRepository.findOneOrFail({
where: { stripePriceId: currentPrices.resourceCreditPriceId },
relations: ['billingProduct'],
});
billingValidator.assertIsMeteredPrice(currentMeteredPrice);
billingValidator.assertIsLicensedResourceCreditPrice(
currentResourceCreditPrice,
);
const targetMeteredPrice =
await this.billingPriceService.findEquivalentMeteredPrice({
meteredPrice: currentMeteredPrice,
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,
meteredPriceId: targetMeteredPrice.stripePriceId,
};
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,
};
}
}
}
@@ -4,11 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { differenceInDays } from 'date-fns';
import {
assertIsDefinedOrThrow,
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Not, type Repository } from 'typeorm';
import type Stripe from 'stripe';
@@ -29,7 +25,6 @@ import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entiti
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 { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
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 { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
@@ -267,36 +262,6 @@ export class BillingSubscriptionService {
};
}
async setBillingThresholdsAndTrialPeriodWorkflowCredits(
billingSubscriptionId: string,
) {
const billingSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { id: billingSubscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
});
const { stripePriceId: meterStripePriceId } = findOrThrow(
billingSubscription.billingSubscriptionItems,
(billingSubscriptionItem) =>
billingSubscriptionItem.billingProduct.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
await this.stripeSubscriptionService.updateSubscription(
billingSubscription.stripeSubscriptionId,
{
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meterStripePriceId,
),
},
);
}
async syncSubscriptionToDatabase(
workspaceId: string,
stripeSubscriptionId: string,
@@ -351,27 +316,29 @@ export class BillingSubscriptionService {
workspaceId,
);
const meterBillingSubscriptionItem = findOrThrow(
billingSubscriptionItems,
// V2 subscriptions have no quantityless metered item; skip the stale-item cleanup in that case
const meterBillingSubscriptionItem = billingSubscriptionItems.find(
(item) => !isDefined(item.quantity),
);
const existingBillingSubscriptionItem =
await this.billingSubscriptionItemRepository.findOne({
where: {
if (isDefined(meterBillingSubscriptionItem)) {
const existingBillingSubscriptionItem =
await this.billingSubscriptionItemRepository.findOne({
where: {
billingSubscriptionId: currentBillingSubscription.id,
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
},
});
if (
existingBillingSubscriptionItem?.stripeSubscriptionItemId !==
meterBillingSubscriptionItem.stripeSubscriptionItemId
) {
await this.billingSubscriptionItemRepository.delete({
billingSubscriptionId: currentBillingSubscription.id,
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
},
});
if (
existingBillingSubscriptionItem?.stripeSubscriptionItemId !==
meterBillingSubscriptionItem.stripeSubscriptionItemId
) {
await this.billingSubscriptionItemRepository.delete({
billingSubscriptionId: currentBillingSubscription.id,
stripeProductId: meterBillingSubscriptionItem.stripeProductId,
});
});
}
}
await this.billingSubscriptionItemRepository.upsert(
@@ -15,6 +15,8 @@ import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing
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 { 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,6 +44,7 @@ export class BillingUsageCapService {
private readonly clickHouseService: ClickHouseService,
private readonly meteredCreditService: MeteredCreditService,
private readonly twentyConfigService: TwentyConfigService,
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(BillingSubscriptionItemEntity)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
) {}
@@ -122,10 +125,60 @@ export class BillingUsageCapService {
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: {
@@ -136,7 +189,7 @@ export class BillingUsageCapService {
billingProduct: {
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
metadata: JSON.stringify({
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
productKey,
}),
}),
},
@@ -145,7 +198,7 @@ export class BillingUsageCapService {
if (billingSubscriptionItems.length !== 1) {
throw new BillingException(
`Expected 1 metered billing subscription item for workspace ${workspaceId}, but got ${billingSubscriptionItems.length}`,
`Expected 1 billing subscription item for workspace ${workspaceId}, but got ${billingSubscriptionItems.length}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
@@ -6,6 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { differenceInDays } from 'date-fns';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import {
@@ -15,6 +16,7 @@ import {
import { type BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-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';
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 { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
@@ -26,10 +28,12 @@ import { buildBillingUsageAvailableCreditsCacheKey } from 'src/engine/core-modul
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;
@@ -54,6 +58,7 @@ export class BillingUsageService {
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly clickHouseService: ClickHouseService,
private readonly billingUsageCapService: BillingUsageCapService,
private readonly featureFlagService: FeatureFlagService,
) {}
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
@@ -136,6 +141,79 @@ export class BillingUsageService {
);
}
async getResourceCreditProductUsage(
workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageDTO[]> {
const subscription =
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
const resourceCreditItemDetail =
await this.billingSubscriptionItemService.getResourceCreditSubscriptionItemDetails(
subscription,
);
if (!isDefined(resourceCreditItemDetail)) {
throw new BillingException(
`Resource credit item not found for workspace ${workspace.id}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
const { periodStart, periodEnd } = this.getSubscriptionPeriod(subscription);
return [
await this.buildResourceCreditUsage(
workspace.id,
subscription,
resourceCreditItemDetail,
periodStart,
periodEnd,
),
];
}
private async buildResourceCreditUsage(
workspaceId: string,
subscription: BillingSubscriptionEntity,
item: NonNullable<
Awaited<
ReturnType<
typeof this.billingSubscriptionItemService.getResourceCreditSubscriptionItemDetails
>
>
>,
periodStart: Date,
periodEnd: Date,
): Promise<BillingMeteredProductUsageDTO> {
const usedCredits = await this.getCurrentPeriodCreditsUsed(
workspaceId,
periodStart,
);
const grantedCredits =
subscription.status === SubscriptionStatus.Trialing
? item.freeTrialQuantity
: item.creditAmount;
const billingCustomer = await this.billingCustomerRepository.findOne({
where: { workspaceId },
});
const rolloverCredits = billingCustomer?.creditBalanceMicro ?? 0;
return {
productKey: item.productKey,
periodStart,
periodEnd,
usedCredits,
grantedCredits,
rolloverCredits,
totalGrantedCredits: grantedCredits + rolloverCredits,
unitPriceCents: item.unitPriceCents,
};
}
//TODO: TO be deprecated
private getSubscriptionPeriod(subscription: BillingSubscriptionEntity): {
periodStart: Date;
@@ -254,30 +332,93 @@ export class BillingUsageService {
);
}
const meteredPricingInfo =
this.meteredCreditService.extractMeteredPricingInfoFromSubscription(
subscription,
const isV2 = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_BILLING_V2_ENABLED,
workspaceId,
);
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;
}
}
getResourceUsageCap(subscription: BillingSubscriptionEntity): number {
const isInFreeTrial = subscription.status === SubscriptionStatus.Trialing;
if (isInFreeTrial) {
const trialDuration =
isDefined(subscription.trialEnd) && isDefined(subscription.trialStart)
? differenceInDays(subscription.trialEnd, subscription.trialStart)
: 0;
const trialWithCreditCardDuration = this.twentyConfigService.get(
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
);
if (!meteredPricingInfo) {
return trialDuration === trialWithCreditCardDuration
? this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD',
)
: this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
);
}
const resourceCreditItem = subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct.metadata?.productKey ===
BillingProductKey.RESOURCE_CREDIT,
);
const resourceCreditPrice =
resourceCreditItem?.billingProduct.billingPrices.find(
(price) => price.stripePriceId === resourceCreditItem.stripePriceId,
);
if (!isDefined(resourceCreditPrice)) {
throw new BillingException(
`No metered item found for workspace ${workspaceId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
`Resource credit price not found for workspace ${subscription.workspaceId}`,
BillingExceptionCode.BILLING_PRICE_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 Number(resourceCreditPrice.metadata?.credit_amount ?? 0);
}
async decrementAvailableCredits({
@@ -20,6 +20,11 @@ export type MeteredPricingInfo = {
stripeMeterId?: string;
};
export type ResourceCreditPricingInfo = {
tierCap: number;
unitPriceCents: number;
};
@Injectable()
export class MeteredCreditService {
protected readonly logger = new Logger(MeteredCreditService.name);
@@ -186,4 +191,69 @@ export class MeteredCreditService {
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,
};
}
}
@@ -40,4 +40,37 @@ export class StripeInvoiceService {
auto_advance: true,
});
}
async createImmediateUpgradeInvoice({
stripeCustomerId,
stripeSubscriptionId,
diffAmountInCents,
currency,
description,
}: {
stripeCustomerId: string;
stripeSubscriptionId: string;
diffAmountInCents: number;
currency: string;
description: string;
}): Promise<void> {
await this.stripe.invoiceItems.create({
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
amount: diffAmountInCents,
currency,
description,
});
const invoice = await this.stripe.invoices.create({
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
});
await this.stripe.invoices.finalizeInvoice(invoice.id, {
auto_advance: true,
});
await this.stripe.invoices.pay(invoice.id);
}
}
@@ -74,7 +74,10 @@ export class StripeSubscriptionScheduleService {
) {
if (!this.stripe) throw new Error('Billing is disabled');
return await this.stripe.subscriptionSchedules.update(scheduleId, params);
return await this.stripe.subscriptionSchedules.update(scheduleId, {
...params,
proration_behavior: 'none',
});
}
async createSubscriptionSchedule(stripeSubscriptionId: string) {
@@ -6,5 +6,6 @@ import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billi
export type BillingGetPlanResult = {
planKey: BillingPlanKey;
meteredProducts: BillingProductEntity[];
licensedProducts: BillingProductEntity[];
baseProducts: BillingProductEntity[];
resourceCreditProducts: BillingProductEntity[];
};
@@ -3,6 +3,7 @@
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
export type BillingGetPricesPerPlanResult = {
meteredProductsPrices: BillingPriceEntity[];
licensedProductsPrices: BillingPriceEntity[];
meteredProductPrices: BillingPriceEntity[];
baseProductPrices: BillingPriceEntity[];
resourceCreditProductPrices: BillingPriceEntity[];
};
@@ -1,8 +1,8 @@
/* @license Enterprise */
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
export type BillingPortalCheckoutSessionParameters = {
@@ -0,0 +1,11 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class BillingPriceMetadata {
@Field(() => String, { nullable: true })
credit_amount?: string;
[key: string]: string | undefined;
}
@@ -4,6 +4,7 @@ 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',
}
@@ -17,6 +18,10 @@ export type SubscriptionUpdate =
type: SubscriptionUpdateType.METERED_PRICE;
newMeteredPriceId: string;
}
| {
type: SubscriptionUpdateType.RESOURCE_CREDIT_PRICE;
newResourceCreditPriceId: string;
}
| {
type: SubscriptionUpdateType.SEATS;
newSeats: number;
@@ -11,7 +11,7 @@ describe('computeSubscriptionUpdateOptions', () => {
});
expect(result).toEqual({
proration: 'create_prorations',
proration: 'always_invoice',
metadata: {
plan: BillingPlanKey.PRO,
},
@@ -25,7 +25,7 @@ describe('computeSubscriptionUpdateOptions', () => {
});
expect(result).toEqual({
proration: 'create_prorations',
proration: 'always_invoice',
metadata: {
plan: BillingPlanKey.ENTERPRISE,
},
@@ -9,7 +9,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
it('should correctly format a billing plan with licensed and metered products', () => {
const mockPlan = {
planKey: BillingPlanKey.PRO,
licensedProducts: [
baseProducts: [
{
id: 'product-1',
name: 'Test Licensed Product',
@@ -23,6 +23,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
id: 'product-2',
@@ -52,7 +53,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
expect(result).toEqual({
planKey: BillingPlanKey.PRO,
licensedProducts: [
baseProducts: [
{
id: 'product-1',
name: 'Test Licensed Product',
@@ -70,10 +71,12 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
unitAmount: 1500,
stripePriceId: 'price_123',
priceUsageType: BillingUsageType.LICENSED,
creditAmount: null,
},
],
},
],
resourceCreditProducts: [],
meteredProducts: [
{
id: 'product-2',
@@ -115,7 +118,8 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
it('should convert internal credits to display credits in metered tier upTo', () => {
const mockPlan = {
planKey: BillingPlanKey.PRO,
licensedProducts: [],
baseProducts: [],
resourceCreditProducts: [],
meteredProducts: [
{
id: 'product-2',
@@ -51,6 +51,7 @@ describe('transformStripePriceToDatabasePrice', () => {
transformQuantity: undefined,
usageType: BillingUsageType.LICENSED,
interval: SubscriptionInterval.Month,
metadata: {},
currencyOptions: undefined,
tiers: undefined,
recurring: {
@@ -17,7 +17,7 @@ export const computeSubscriptionUpdateOptions = (
switch (subscriptionUpdate.type) {
case SubscriptionUpdateType.PLAN:
return {
proration: 'create_prorations',
proration: 'always_invoice',
metadata: {
plan: subscriptionUpdate.newPlan,
},
@@ -26,7 +26,10 @@ export const computeSubscriptionUpdateOptions = (
return {
proration: 'create_prorations',
};
case SubscriptionUpdateType.RESOURCE_CREDIT_PRICE:
return {
proration: 'none',
};
case SubscriptionUpdateType.INTERVAL:
return {
proration: 'create_prorations',
@@ -1,20 +1,32 @@
/* @license Enterprise */
import { isDefined } from 'class-validator';
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import {
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
toDisplayCredits,
} from 'src/engine/core-modules/usage/utils/to-display-credits.util';
export const formatBillingDatabaseProductToGraphqlDTO = (
plan: BillingGetPlanResult,
): BillingPlanDTO => {
return {
planKey: plan.planKey,
licensedProducts: plan.licensedProducts.map((product) => {
baseProducts: plan.baseProducts.map((product) => {
return {
...product,
prices: product.billingPrices.map(
formatBillingDatabasePriceToLicensedPriceDTO,
),
};
}),
resourceCreditProducts: plan.resourceCreditProducts.map((product) => {
return {
...product,
prices: product.billingPrices.map(
@@ -61,5 +73,9 @@ const formatBillingDatabasePriceToLicensedPriceDTO = (
unitAmount: billingPrice?.unitAmount ?? 0,
stripePriceId: billingPrice?.stripePriceId,
priceUsageType: BillingUsageType.LICENSED,
creditAmount: isDefined(billingPrice?.metadata?.credit_amount)
? Number(billingPrice?.metadata?.credit_amount) /
INTERNAL_CREDITS_PER_DISPLAY_CREDIT
: null,
};
};
@@ -0,0 +1,19 @@
/* @license Enterprise */
import { findOrThrow } from 'twenty-shared/utils';
import { type 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 { type LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
// V2 counterpart of get-licensed-billing-subscription-item-or-throw.util.ts
// Identifies the base plan item by productKey === BASE_PRODUCT (not quantity != null)
export const getBaseProductSubscriptionItemOrThrow = (
billingSubscription: BillingSubscriptionEntity,
): LicensedBillingSubscriptionItem => {
return findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.productKey === BillingProductKey.BASE_PRODUCT,
) as LicensedBillingSubscriptionItem;
};
@@ -0,0 +1,18 @@
/* @license Enterprise */
import { findOrThrow } from 'twenty-shared/utils';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
// V2 counterpart of get-metered-billing-subscription-item-or-throw.util.ts
// Identifies the credit item by productKey === RESOURCE_CREDIT (not quantity == null)
export const getCurrentResourceCreditSubscriptionItemOrThrow = (
billingSubscription: BillingSubscriptionEntity,
) => {
return findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.productKey === BillingProductKey.RESOURCE_CREDIT,
);
};
@@ -36,6 +36,7 @@ export const transformStripePriceToDatabasePrice = (data: Stripe.Price) => {
data.currency_options === null ? undefined : data.currency_options,
tiers: data.tiers === null ? undefined : data.tiers,
recurring: data.recurring === null ? undefined : data.recurring,
metadata: data.metadata ?? {},
};
};