disable workflow execution if billing issue (#11374)
closes https://github.com/twentyhq/core-team-issues/issues/404
This commit is contained in:
@ -0,0 +1,2 @@
|
||||
export const BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE =
|
||||
'No remaining credits to execute workflow. Please check your subscription.';
|
||||
@ -0,0 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export enum BillingProductKey {
|
||||
BASE_PRODUCT = 'BASE_PRODUCT',
|
||||
WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION',
|
||||
}
|
||||
@ -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<BillingProduct[]> {
|
||||
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 {
|
||||
|
||||
@ -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<BillingEntitlement>,
|
||||
@InjectRepository(BillingSubscription, 'core')
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||
@InjectRepository(BillingProduct, 'core')
|
||||
private readonly billingProductRepository: Repository<BillingProduct>,
|
||||
) {}
|
||||
|
||||
async getCurrentBillingSubscriptionOrThrow(criteria: {
|
||||
|
||||
@ -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<BillingSubscription>,
|
||||
) {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, never>;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = <T>(
|
||||
value: string | undefined,
|
||||
enumObject: Record<string, T>,
|
||||
): boolean => {
|
||||
return Object.values(enumObject).includes(value as T);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user