add billing threshold + specific trial free credits (#11533)

In this PR : 
- set billing thresholds after subscription creation (not possible
during billing checkout)
- add specific free trial workflow credit quantities + set them in
subscription item + check them when receiving stripe alert event

closes : https://github.com/twentyhq/core-team-issues/issues/682
This commit is contained in:
Etienne
2025-04-14 18:25:07 +02:00
committed by GitHub
parent 704b18af30
commit 9a69cd0b61
17 changed files with 218 additions and 21 deletions

View File

@ -147,7 +147,8 @@ export type BillingEndTrialPeriodOutput = {
export type BillingMeteredProductUsageOutput = { export type BillingMeteredProductUsageOutput = {
__typename?: 'BillingMeteredProductUsageOutput'; __typename?: 'BillingMeteredProductUsageOutput';
includedFreeQuantity: Scalars['Float']; freeTierQuantity: Scalars['Float'];
freeTrialQuantity: Scalars['Float'];
periodEnd: Scalars['DateTime']; periodEnd: Scalars['DateTime'];
periodStart: Scalars['DateTime']; periodStart: Scalars['DateTime'];
productKey: BillingProductKey; productKey: BillingProductKey;
@ -2587,7 +2588,7 @@ export type EndSubscriptionTrialPeriodMutation = { __typename?: 'Mutation', endS
export type GetMeteredProductsUsageQueryVariables = Exact<{ [key: string]: never; }>; export type GetMeteredProductsUsageQueryVariables = Exact<{ [key: string]: never; }>;
export type GetMeteredProductsUsageQuery = { __typename?: 'Query', getMeteredProductsUsage: Array<{ __typename?: 'BillingMeteredProductUsageOutput', productKey: BillingProductKey, usageQuantity: number, includedFreeQuantity: number, unitPriceCents: number, totalCostCents: number }> }; export type GetMeteredProductsUsageQuery = { __typename?: 'Query', getMeteredProductsUsage: Array<{ __typename?: 'BillingMeteredProductUsageOutput', productKey: BillingProductKey, usageQuantity: number, freeTierQuantity: number, freeTrialQuantity: number, unitPriceCents: number, totalCostCents: number }> };
export type SwitchSubscriptionToYearlyIntervalMutationVariables = Exact<{ [key: string]: never; }>; export type SwitchSubscriptionToYearlyIntervalMutationVariables = Exact<{ [key: string]: never; }>;
@ -4252,7 +4253,8 @@ export const GetMeteredProductsUsageDocument = gql`
getMeteredProductsUsage { getMeteredProductsUsage {
productKey productKey
usageQuantity usageQuantity
includedFreeQuantity freeTierQuantity
freeTrialQuantity
unitPriceCents unitPriceCents
totalCostCents totalCostCents
} }

View File

@ -5,7 +5,8 @@ export const GET_METERED_PRODUCTS_USAGE = gql`
getMeteredProductsUsage { getMeteredProductsUsage {
productKey productKey
usageQuantity usageQuantity
includedFreeQuantity freeTierQuantity
freeTrialQuantity
unitPriceCents unitPriceCents
totalCostCents totalCostCents
} }

View File

@ -1,9 +1,13 @@
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { import {
BillingProductKey, BillingProductKey,
SubscriptionStatus,
useGetMeteredProductsUsageQuery, useGetMeteredProductsUsageQuery,
} from '~/generated/graphql'; } from '~/generated/graphql';
export const useGetWorkflowNodeExecutionUsage = () => { export const useGetWorkflowNodeExecutionUsage = () => {
const subscriptionStatus = useSubscriptionStatus();
const { data, loading } = useGetMeteredProductsUsageQuery(); const { data, loading } = useGetMeteredProductsUsageQuery();
const workflowUsage = data?.getMeteredProductsUsage.find( const workflowUsage = data?.getMeteredProductsUsage.find(
@ -22,16 +26,21 @@ export const useGetWorkflowNodeExecutionUsage = () => {
}; };
} }
const includedFreeQuantity =
subscriptionStatus === SubscriptionStatus.Trialing
? workflowUsage.freeTrialQuantity
: workflowUsage.freeTierQuantity;
return { return {
usageQuantity: workflowUsage.usageQuantity, usageQuantity: workflowUsage.usageQuantity,
freeUsageQuantity: freeUsageQuantity:
workflowUsage.usageQuantity > workflowUsage.includedFreeQuantity workflowUsage.usageQuantity > includedFreeQuantity
? workflowUsage.includedFreeQuantity ? includedFreeQuantity
: workflowUsage.usageQuantity, : workflowUsage.usageQuantity,
includedFreeQuantity: workflowUsage.includedFreeQuantity, includedFreeQuantity,
paidUsageQuantity: paidUsageQuantity:
workflowUsage.usageQuantity > workflowUsage.includedFreeQuantity workflowUsage.usageQuantity > includedFreeQuantity
? workflowUsage.usageQuantity - workflowUsage.includedFreeQuantity ? workflowUsage.usageQuantity - includedFreeQuantity
: 0, : 0,
unitPriceCents: workflowUsage.unitPriceCents, unitPriceCents: workflowUsage.unitPriceCents,
totalCostCents: workflowUsage.totalCostCents, totalCostCents: workflowUsage.totalCostCents,

View File

@ -14,6 +14,7 @@ export enum BillingExceptionCode {
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND', BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND', BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND', BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND',
BILLING_SUBSCRIPTION_ITEM_NOT_FOUND = 'BILLING_SUBSCRIPTION_ITEM_NOT_FOUND',
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND', BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND', BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND',
BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED', BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED',

View File

@ -17,7 +17,10 @@ export class BillingMeteredProductUsageOutput {
usageQuantity: number; usageQuantity: number;
@Field(() => Number) @Field(() => Number)
includedFreeQuantity: number; freeTierQuantity: number;
@Field(() => Number)
freeTrialQuantity: number;
@Field(() => Number) @Field(() => Number)
unitPriceCents: number; unitPriceCents: number;

View File

@ -15,6 +15,7 @@ import {
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItemMetadata } from 'src/engine/core-modules/billing/types/billing-subscription-item-metadata.type';
@Entity({ name: 'billingSubscriptionItem', schema: 'core' }) @Entity({ name: 'billingSubscriptionItem', schema: 'core' })
@Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [ @Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [
'billingSubscriptionId', 'billingSubscriptionId',
@ -40,7 +41,7 @@ export class BillingSubscriptionItem {
stripeSubscriptionId: string; stripeSubscriptionId: string;
@Column({ nullable: false, type: 'jsonb', default: {} }) @Column({ nullable: false, type: 'jsonb', default: {} })
metadata: Stripe.Metadata; metadata: BillingSubscriptionItemMetadata;
@Column({ nullable: true, type: 'jsonb' }) @Column({ nullable: true, type: 'jsonb' })
billingThresholds: Stripe.SubscriptionItem.BillingThresholds; billingThresholds: Stripe.SubscriptionItem.BillingThresholds;

View File

@ -43,6 +43,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND: case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND: case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
case BillingExceptionCode.BILLING_METER_NOT_FOUND: case BillingExceptionCode.BILLING_METER_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
return this.httpExceptionHandlerService.handleError( return this.httpExceptionHandlerService.handleError(
exception, exception,
response, response,

View File

@ -45,7 +45,7 @@ export class UpdateSubscriptionQuantityJob {
await this.stripeSubscriptionItemService.updateSubscriptionItem( await this.stripeSubscriptionItemService.updateSubscriptionItem(
billingBaseProductSubscriptionItem.stripeSubscriptionItemId, billingBaseProductSubscriptionItem.stripeSubscriptionItemId,
workspaceMembersCount, { quantity: workspaceMembersCount },
); );
this.logger.log( this.logger.log(

View File

@ -9,13 +9,16 @@ import {
} from 'src/engine/core-modules/billing/billing.exception'; } from 'src/engine/core-modules/billing/billing.exception';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable() @Injectable()
export class BillingSubscriptionItemService { export class BillingSubscriptionItemService {
constructor( constructor(
@InjectRepository(BillingSubscriptionItem, 'core') @InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>, private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
private readonly twentyConfigService: TwentyConfigService,
) {} ) {}
async getMeteredSubscriptionItemDetails(subscriptionId: string) { async getMeteredSubscriptionItemDetails(subscriptionId: string) {
@ -48,7 +51,8 @@ export class BillingSubscriptionItemService {
stripeSubscriptionItemId: item.stripeSubscriptionItemId, stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: item.billingProduct.metadata.productKey, productKey: item.billingProduct.metadata.productKey,
stripeMeterId, stripeMeterId,
includedFreeQuantity: this.getIncludedFreeQuantity(price), freeTierQuantity: this.getFreeTierQuantity(price),
freeTrialQuantity: this.getFreeTrialQuantity(item),
unitPriceCents: this.getUnitPrice(price), unitPriceCents: this.getUnitPrice(price),
}; };
}); });
@ -69,10 +73,24 @@ export class BillingSubscriptionItemService {
return matchingPrice; return matchingPrice;
} }
private getIncludedFreeQuantity(price: BillingPrice): number { private getFreeTierQuantity(price: BillingPrice): number {
return price.tiers?.find((tier) => tier.unit_amount === 0)?.up_to || 0; return price.tiers?.find((tier) => tier.unit_amount === 0)?.up_to || 0;
} }
private getFreeTrialQuantity(item: BillingSubscriptionItem): number {
switch (item.billingProduct.metadata.productKey) {
case BillingProductKey.WORKFLOW_NODE_EXECUTION:
return (
item.metadata.trialPeriodFreeWorkflowCredits ||
this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
)
);
default:
return 0;
}
}
private getUnitPrice(price: BillingPrice): number { private getUnitPrice(price: BillingPrice): number {
return Number( return Number(
price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal || price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal ||

View File

@ -5,7 +5,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert'; import assert from 'assert';
import { differenceInDays } from 'date-fns';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { isDefined } from 'twenty-shared/utils';
import { Not, Repository } from 'typeorm'; import { Not, Repository } from 'typeorm';
import { import {
@ -17,14 +19,17 @@ import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-p
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.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 { 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 { 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 { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.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 { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util'; import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
import { getSubscriptionStatus } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util'; import { getSubscriptionStatus } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable() @Injectable()
export class BillingSubscriptionService { export class BillingSubscriptionService {
@ -38,6 +43,8 @@ export class BillingSubscriptionService {
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>, private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
private readonly stripeCustomerService: StripeCustomerService, private readonly stripeCustomerService: StripeCustomerService,
private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
) {} ) {}
async getCurrentBillingSubscriptionOrThrow(criteria: { async getCurrentBillingSubscriptionOrThrow(criteria: {
@ -238,4 +245,78 @@ export class BillingSubscriptionService {
hasPaymentMethod: true, hasPaymentMethod: true,
}; };
} }
async setBillingThresholdsAndTrialPeriodWorkflowCredits(
billingSubscriptionId: string,
) {
const billingSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { id: billingSubscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
});
await this.stripeSubscriptionService.updateSubscription(
billingSubscription.stripeSubscriptionId,
{
billing_thresholds: {
amount_gte: this.twentyConfigService.get(
'BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT',
),
reset_billing_cycle_anchor: false,
},
},
);
const workflowSubscriptionItem =
billingSubscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!workflowSubscriptionItem) {
throw new BillingException(
'Workflow subscription item not found',
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
await this.stripeSubscriptionItemService.updateSubscriptionItem(
workflowSubscriptionItem.stripeSubscriptionItemId,
{
metadata: {
trialPeriodFreeWorkflowCredits:
this.getTrialPeriodFreeWorkflowCredits(billingSubscription),
},
},
);
}
private getTrialPeriodFreeWorkflowCredits(
billingSubscription: BillingSubscription,
) {
const trialDuration =
isDefined(billingSubscription.trialEnd) &&
isDefined(billingSubscription.trialStart)
? differenceInDays(
billingSubscription.trialEnd,
billingSubscription.trialStart,
)
: 0;
const trialWithCreditCardDuration = this.twentyConfigService.get(
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
);
return Number(
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',
),
);
}
} }

View File

@ -125,8 +125,8 @@ export class BillingUsageService {
); );
const totalCostCents = const totalCostCents =
meterEventsSum - item.includedFreeQuantity > 0 meterEventsSum - item.freeTierQuantity > 0
? (meterEventsSum - item.includedFreeQuantity) * item.unitPriceCents ? (meterEventsSum - item.freeTierQuantity) * item.unitPriceCents
: 0; : 0;
return { return {
@ -134,7 +134,8 @@ export class BillingUsageService {
periodStart, periodStart,
periodEnd, periodEnd,
usageQuantity: meterEventsSum, usageQuantity: meterEventsSum,
includedFreeQuantity: item.includedFreeQuantity, freeTierQuantity: item.freeTierQuantity,
freeTrialQuantity: item.freeTrialQuantity,
unitPriceCents: item.unitPriceCents, unitPriceCents: item.unitPriceCents,
totalCostCents, totalCostCents,
}; };

View File

@ -24,8 +24,11 @@ export class StripeSubscriptionItemService {
); );
} }
async updateSubscriptionItem(stripeItemId: string, quantity: number) { async updateSubscriptionItem(
await this.stripe.subscriptionItems.update(stripeItemId, { quantity }); stripeItemId: string,
updateData: Stripe.SubscriptionItemUpdateParams,
) {
await this.stripe.subscriptionItems.update(stripeItemId, updateData);
} }
async createSubscriptionItem( async createSubscriptionItem(

View File

@ -6,7 +6,7 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
@ObjectType('BillingProductMetadata') @ObjectType()
export class BillingProductMetadata { export class BillingProductMetadata {
@Field(() => BillingPlanKey) @Field(() => BillingPlanKey)
planKey: BillingPlanKey; planKey: BillingPlanKey;

View File

@ -0,0 +1,7 @@
/* @license Enterprise */
export type BillingSubscriptionItemMetadata =
| {
trialPeriodFreeWorkflowCredits: number;
}
| Record<string, never>;

View File

@ -38,7 +38,10 @@ export class BillingWebhookAlertService {
if (alert.title === TRIAL_PERIOD_ALERT_TITLE && isDefined(stripeMeterId)) { if (alert.title === TRIAL_PERIOD_ALERT_TITLE && isDefined(stripeMeterId)) {
const subscription = await this.billingSubscriptionRepository.findOne({ const subscription = await this.billingSubscriptionRepository.findOne({
where: { stripeCustomerId, status: SubscriptionStatus.Trialing }, where: { stripeCustomerId, status: SubscriptionStatus.Trialing },
relations: ['billingSubscriptionItems'], relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
}); });
if (!subscription) return; if (!subscription) return;
@ -56,6 +59,24 @@ export class BillingWebhookAlertService {
); );
} }
const subscriptionItem = subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct.stripeProductId === product.stripeProductId,
);
const trialPeriodFreeWorkflowCredits = isDefined(
subscriptionItem?.metadata.trialPeriodFreeWorkflowCredits,
)
? Number(subscriptionItem?.metadata.trialPeriodFreeWorkflowCredits)
: 0;
if (
!isDefined(alert.usage_threshold?.gte) ||
trialPeriodFreeWorkflowCredits !== alert.usage_threshold.gte
) {
return;
}
await this.billingSubscriptionItemRepository.update( await this.billingSubscriptionItemRepository.update(
{ {
billingSubscriptionId: subscription.id, billingSubscriptionId: subscription.id,

View File

@ -13,10 +13,12 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum'; import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util'; import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util'; import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util'; import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@ -44,6 +46,7 @@ export class BillingWebhookSubscriptionService {
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(BillingCustomer, 'core') @InjectRepository(BillingCustomer, 'core')
private readonly billingCustomerRepository: Repository<BillingCustomer>, private readonly billingCustomerRepository: Repository<BillingCustomer>,
private readonly billingSubscriptionService: BillingSubscriptionService,
@InjectRepository(FeatureFlag, 'core') @InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>, private readonly featureFlagRepository: Repository<FeatureFlag>,
) {} ) {}
@ -137,6 +140,24 @@ export class BillingWebhookSubscriptionService {
workspaceId, workspaceId,
); );
const isMeteredProductBillingEnabled =
await this.featureFlagRepository.findOne({
where: {
key: FeatureFlagKey.IsMeteredProductBillingEnabled,
workspaceId: workspaceId,
value: true,
},
});
if (
event.type === BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED &&
isDefined(isMeteredProductBillingEnabled)
) {
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
updatedBillingSubscription.id,
);
}
return { return {
stripeSubscriptionId: data.object.id, stripeSubscriptionId: data.object.id,
stripeCustomerId: data.object.customer, stripeCustomerId: data.object.customer,

View File

@ -509,6 +509,33 @@ export class ConfigVariables {
@ValidateIf((env) => env.IS_BILLING_ENABLED === true) @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS = 7; BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS = 7;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of money in cents to trigger a billing threshold',
})
@IsNumber()
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT = 10000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial without credit card',
})
@IsNumber()
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD = 5000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial with credit card',
})
@IsNumber()
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD = 10000;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
isSensitive: true, isSensitive: true,