diff --git a/packages/twenty-server/src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.constant.ts b/packages/twenty-server/src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.constant.ts new file mode 100644 index 000000000..77617e2b8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.constant.ts @@ -0,0 +1,2 @@ +export const BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE = + 'No remaining credits to execute workflow. Please check your subscription.'; diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-product-key.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-product-key.enum.ts new file mode 100644 index 000000000..6d1af2d98 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-product-key.enum.ts @@ -0,0 +1,6 @@ +/* @license Enterprise */ + +export enum BillingProductKey { + BASE_PRODUCT = 'BASE_PRODUCT', + WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION', +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts index c455cf6ca..5a5071797 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts @@ -11,6 +11,7 @@ import { } from 'src/engine/core-modules/billing/billing.exception'; import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; import { 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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; import { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type'; @@ -27,18 +28,18 @@ export class BillingPlanService { async getProductsByProductMetadata({ planKey, priceUsageBased, - isBaseProduct, + productKey, }: { planKey: BillingPlanKey; priceUsageBased: BillingUsageType; - isBaseProduct: 'true' | 'false'; + productKey: BillingProductKey; }): Promise { const products = await this.billingProductRepository.find({ where: { metadata: JsonContains({ priceUsageBased, planKey, - isBaseProduct, + productKey, }), active: true, }, @@ -52,7 +53,7 @@ export class BillingPlanService { const [baseProduct] = await this.getProductsByProductMetadata({ planKey, priceUsageBased: BillingUsageType.LICENSED, - isBaseProduct: 'true', + productKey: BillingProductKey.BASE_PRODUCT, }); return baseProduct; @@ -80,7 +81,8 @@ export class BillingPlanService { }; }); const baseProduct = planProducts.find( - (product) => product.metadata.isBaseProduct === 'true', + (product) => + product.metadata.productKey === BillingProductKey.BASE_PRODUCT, ); if (!baseProduct) { @@ -97,7 +99,7 @@ export class BillingPlanService { const otherLicensedProducts = planProducts.filter( (product) => product.metadata.priceUsageBased === BillingUsageType.LICENSED && - product.metadata.isBaseProduct === 'false', + product.metadata.productKey !== BillingProductKey.BASE_PRODUCT, ); return { diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index 21accb26e..126a7899c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -14,7 +14,6 @@ import { } from 'src/engine/core-modules/billing/billing.exception'; import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; -import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; @@ -38,8 +37,6 @@ export class BillingSubscriptionService { private readonly billingEntitlementRepository: Repository, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, - @InjectRepository(BillingProduct, 'core') - private readonly billingProductRepository: Repository, ) {} async getCurrentBillingSubscriptionOrThrow(criteria: { diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts index 515189344..2ec8dc157 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts @@ -8,8 +8,14 @@ import { Repository } from 'typeorm'; import { BillingSubscription } 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 { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; +import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class BillingService { @@ -17,6 +23,8 @@ export class BillingService { constructor( private readonly environmentService: EnvironmentService, private readonly billingSubscriptionService: BillingSubscriptionService, + private readonly billingProductService: BillingProductService, + private readonly featureFlagService: FeatureFlagService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, ) {} @@ -61,4 +69,44 @@ export class BillingService { return !hasAnySubscription; } + + async canBillMeteredProduct( + workspaceId: string, + productKey: BillingProductKey, + ) { + const isMeteredProductBillingEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsMeteredProductBillingEnabled, + workspaceId, + ); + + if (!isMeteredProductBillingEnabled) { + return true; + } + + const subscription = + await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow( + { workspaceId }, + ); + + if ( + ![SubscriptionStatus.Active, SubscriptionStatus.Trialing].includes( + subscription.status, + ) + ) { + return false; + } + + const planKey = getPlanKeyFromSubscription(subscription); + const products = + await this.billingProductService.getProductsByPlan(planKey); + const targetProduct = products.find( + ({ metadata }) => metadata.productKey === productKey, + ); + const subscriptionItem = subscription.billingSubscriptionItems.find( + (item) => item.stripeProductId === targetProduct?.stripeProductId, + ); + + return subscriptionItem?.hasReachedCurrentPeriodCap === false; + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts index 3cf21f928..789a855ef 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-product-metadata.type.ts @@ -1,13 +1,14 @@ /* @license Enterprise */ import { 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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; export type BillingProductMetadata = | { planKey: BillingPlanKey; priceUsageBased: BillingUsageType; - isBaseProduct: 'true' | 'false'; + productKey: BillingProductKey; [key: string]: string; } | Record; diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts index b1f2aae55..59636a08e 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts @@ -3,6 +3,7 @@ import Stripe from 'stripe'; import { 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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util'; describe('isStripeValidProductMetadata', () => { @@ -15,7 +16,7 @@ describe('isStripeValidProductMetadata', () => { const metadata: Stripe.Metadata = { planKey: BillingPlanKey.PRO, priceUsageBased: BillingUsageType.METERED, - isBaseProduct: 'true', + productKey: BillingProductKey.BASE_PRODUCT, }; expect(isStripeValidProductMetadata(metadata)).toBe(true); @@ -25,7 +26,7 @@ describe('isStripeValidProductMetadata', () => { const metadata: Stripe.Metadata = { planKey: BillingPlanKey.ENTERPRISE, priceUsageBased: BillingUsageType.METERED, - isBaseProduct: 'false', + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, randomKey: 'randomValue', }; @@ -36,7 +37,7 @@ describe('isStripeValidProductMetadata', () => { const metadata: Stripe.Metadata = { planKey: 'invalid', priceUsageBased: BillingUsageType.METERED, - isBaseProduct: 'invalid', + productKey: 'invalid', }; expect(isStripeValidProductMetadata(metadata)).toBe(false); @@ -46,7 +47,7 @@ describe('isStripeValidProductMetadata', () => { const metadata: Stripe.Metadata = { planKey: BillingPlanKey.PRO, priceUsageBased: 'invalid', - isBaseProduct: 'true', + productKey: BillingProductKey.BASE_PRODUCT, }; expect(isStripeValidProductMetadata(metadata)).toBe(false); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util.ts index cc1c340a6..5e2f55cdf 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util.ts @@ -3,6 +3,7 @@ import Stripe from 'stripe'; import { 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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type'; @@ -12,32 +13,22 @@ export function isStripeValidProductMetadata( if (Object.keys(metadata).length === 0) { return true; } - const hasBillingPlanKey = isValidBillingPlanKey(metadata.planKey); - const hasPriceUsageBased = isValidPriceUsageBased(metadata.priceUsageBased); - const hasIsBaseProduct = - metadata.isBaseProduct === 'true' || metadata.isBaseProduct === 'false'; + const hasBillingPlanKey = isValidEnumValue(metadata.planKey, BillingPlanKey); + const hasPriceUsageBased = isValidEnumValue( + metadata.priceUsageBased, + BillingUsageType, + ); + const hasProductKey = isValidEnumValue( + metadata.productKey, + BillingProductKey, + ); - return hasBillingPlanKey && hasPriceUsageBased && hasIsBaseProduct; + return hasBillingPlanKey && hasPriceUsageBased && hasProductKey; } -const isValidBillingPlanKey = (planKey?: string) => { - switch (planKey) { - case BillingPlanKey.ENTERPRISE: - return true; - case BillingPlanKey.PRO: - return true; - default: - return false; - } -}; - -const isValidPriceUsageBased = (priceUsageBased?: string) => { - switch (priceUsageBased) { - case BillingUsageType.METERED: - return true; - case BillingUsageType.LICENSED: - return true; - default: - return false; - } +const isValidEnumValue = ( + value: string | undefined, + enumObject: Record, +): boolean => { + return Object.values(enumObject).includes(value as T); }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts index 318eab2bd..462dd2805 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory'; @@ -18,6 +19,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow RecordCRUDActionModule, FormActionModule, WorkflowRunModule, + BillingModule, ], providers: [ WorkflowExecutorWorkspaceService, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts index eb1cb3be3..4083991f0 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts @@ -1,7 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant'; +import { BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE } from 'src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.constant'; import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names'; +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory'; @@ -39,6 +41,11 @@ describe('WorkflowExecutorWorkspaceService', () => { saveWorkflowRunState: jest.fn(), }; + const mockBillingService = { + isBillingEnabled: jest.fn(), + canBillMeteredProduct: jest.fn(), + }; + beforeEach(async () => { jest.clearAllMocks(); @@ -63,6 +70,10 @@ describe('WorkflowExecutorWorkspaceService', () => { provide: WorkflowRunWorkspaceService, useValue: mockWorkflowRunWorkspaceService, }, + { + provide: BillingService, + useValue: mockBillingService, + }, ], }).compile(); @@ -376,6 +387,35 @@ describe('WorkflowExecutorWorkspaceService', () => { }); expect(result).toEqual(errorOutput); }); + + it('should stop when billing validation fails', async () => { + mockBillingService.isBillingEnabled.mockReturnValueOnce(true); + mockBillingService.canBillMeteredProduct.mockReturnValueOnce(false); + + const result = await service.execute({ + workflowRunId: mockWorkflowRunId, + currentStepIndex: 0, + steps: mockSteps, + context: mockContext, + }); + + expect(workflowExecutorFactory.get).toHaveBeenCalledTimes(1); + expect( + workflowRunWorkspaceService.saveWorkflowRunState, + ).toHaveBeenCalledWith({ + workflowRunId: mockWorkflowRunId, + stepOutput: { + id: 'step-1', + output: { + error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE, + }, + }, + context: mockContext, + }); + expect(result).toEqual({ + error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE, + }); + }); }); describe('sendWorkflowNodeRunEvent', () => { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts index 3269df82a..1e3148624 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts @@ -3,7 +3,10 @@ import { Injectable } from '@nestjs/common'; import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface'; import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant'; +import { BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE } from 'src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.constant'; import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names'; +import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; @@ -31,6 +34,7 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService, + private readonly billingService: BillingService, ) {} async execute({ @@ -54,6 +58,26 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { let actionOutput: WorkflowExecutorOutput; + if ( + this.billingService.isBillingEnabled() && + !(await this.canBillWorkflowNodeExecution()) + ) { + const billingOutput = { + error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE, + }; + + await this.workflowRunWorkspaceService.saveWorkflowRunState({ + workflowRunId, + stepOutput: { + id: step.id, + output: billingOutput, + }, + context, + }); + + return billingOutput; + } + try { actionOutput = await workflowExecutor.execute({ currentStepIndex, @@ -159,4 +183,14 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { workspaceId, ); } + + private async canBillWorkflowNodeExecution() { + const workspaceId = + this.scopedWorkspaceContextFactory.create().workspaceId ?? ''; + + return this.billingService.canBillMeteredProduct( + workspaceId, + BillingProductKey.WORKFLOW_NODE_EXECUTION, + ); + } } diff --git a/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-product-updated-data.util.ts b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-product-updated-data.util.ts index 8f771dd7d..d56089ac0 100644 --- a/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-product-updated-data.util.ts +++ b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-product-updated-data.util.ts @@ -1,5 +1,6 @@ import Stripe from 'stripe'; +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'; export const createMockStripeProductUpdatedData = ( @@ -17,8 +18,8 @@ export const createMockStripeProductUpdatedData = ( marketing_features: [], metadata: { planKey: 'base', - isBaseProduct: 'true', priceUsageBased: BillingUsageType.LICENSED, + productKey: BillingProductKey.BASE_PRODUCT, }, name: 'kjnnjkjknkjnjkn', package_dimensions: null,