disable workflow execution if billing issue (#11374)

closes https://github.com/twentyhq/core-team-issues/issues/404
This commit is contained in:
Etienne
2025-04-03 16:18:44 +02:00
committed by GitHub
parent 7eec64b6e0
commit 752eb93836
12 changed files with 165 additions and 40 deletions

View File

@ -0,0 +1,2 @@
export const BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE =
'No remaining credits to execute workflow. Please check your subscription.';

View File

@ -0,0 +1,6 @@
/* @license Enterprise */
export enum BillingProductKey {
BASE_PRODUCT = 'BASE_PRODUCT',
WORKFLOW_NODE_EXECUTION = 'WORKFLOW_NODE_EXECUTION',
}

View File

@ -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 {

View File

@ -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: {

View File

@ -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;
}
}

View File

@ -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>;

View File

@ -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);

View File

@ -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);
};