From 9a69cd0b618b9f2fa1ff86842054a920ea269b3e Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Mon, 14 Apr 2025 18:25:07 +0200 Subject: [PATCH] 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 --- .../twenty-front/src/generated/graphql.tsx | 8 +- .../graphql/getMeteredProductsUsage.ts | 3 +- .../hooks/useGetWorkflowNodeExecutionUsage.ts | 19 +++-- .../core-modules/billing/billing.exception.ts | 1 + .../billing-metered-product-usage.output.ts | 5 +- .../billing-subscription-item.entity.ts | 3 +- .../filters/billing-api-exception.filter.ts | 1 + .../jobs/update-subscription-quantity.job.ts | 2 +- .../billing-subscription-item.service.ts | 22 ++++- .../services/billing-subscription.service.ts | 81 +++++++++++++++++++ .../billing/services/billing-usage.service.ts | 7 +- .../stripe-subscription-item.service.ts | 7 +- .../types/billing-product-metadata.type.ts | 2 +- ...billing-subscription-item-metadata.type.ts | 7 ++ .../services/billing-webhook-alert.service.ts | 23 +++++- .../billing-webhook-subscription.service.ts | 21 +++++ .../twenty-config/config-variables.ts | 27 +++++++ 17 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/types/billing-subscription-item-metadata.type.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 8aacc9bfe..8825ebd00 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -147,7 +147,8 @@ export type BillingEndTrialPeriodOutput = { export type BillingMeteredProductUsageOutput = { __typename?: 'BillingMeteredProductUsageOutput'; - includedFreeQuantity: Scalars['Float']; + freeTierQuantity: Scalars['Float']; + freeTrialQuantity: Scalars['Float']; periodEnd: Scalars['DateTime']; periodStart: Scalars['DateTime']; productKey: BillingProductKey; @@ -2587,7 +2588,7 @@ export type EndSubscriptionTrialPeriodMutation = { __typename?: 'Mutation', endS 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; }>; @@ -4252,7 +4253,8 @@ export const GetMeteredProductsUsageDocument = gql` getMeteredProductsUsage { productKey usageQuantity - includedFreeQuantity + freeTierQuantity + freeTrialQuantity unitPriceCents totalCostCents } diff --git a/packages/twenty-front/src/modules/billing/graphql/getMeteredProductsUsage.ts b/packages/twenty-front/src/modules/billing/graphql/getMeteredProductsUsage.ts index 3732aee09..99f290bc1 100644 --- a/packages/twenty-front/src/modules/billing/graphql/getMeteredProductsUsage.ts +++ b/packages/twenty-front/src/modules/billing/graphql/getMeteredProductsUsage.ts @@ -5,7 +5,8 @@ export const GET_METERED_PRODUCTS_USAGE = gql` getMeteredProductsUsage { productKey usageQuantity - includedFreeQuantity + freeTierQuantity + freeTrialQuantity unitPriceCents totalCostCents } diff --git a/packages/twenty-front/src/modules/billing/hooks/useGetWorkflowNodeExecutionUsage.ts b/packages/twenty-front/src/modules/billing/hooks/useGetWorkflowNodeExecutionUsage.ts index fa304f2e9..f52a6ebcf 100644 --- a/packages/twenty-front/src/modules/billing/hooks/useGetWorkflowNodeExecutionUsage.ts +++ b/packages/twenty-front/src/modules/billing/hooks/useGetWorkflowNodeExecutionUsage.ts @@ -1,9 +1,13 @@ +import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; import { BillingProductKey, + SubscriptionStatus, useGetMeteredProductsUsageQuery, } from '~/generated/graphql'; export const useGetWorkflowNodeExecutionUsage = () => { + const subscriptionStatus = useSubscriptionStatus(); + const { data, loading } = useGetMeteredProductsUsageQuery(); const workflowUsage = data?.getMeteredProductsUsage.find( @@ -22,16 +26,21 @@ export const useGetWorkflowNodeExecutionUsage = () => { }; } + const includedFreeQuantity = + subscriptionStatus === SubscriptionStatus.Trialing + ? workflowUsage.freeTrialQuantity + : workflowUsage.freeTierQuantity; + return { usageQuantity: workflowUsage.usageQuantity, freeUsageQuantity: - workflowUsage.usageQuantity > workflowUsage.includedFreeQuantity - ? workflowUsage.includedFreeQuantity + workflowUsage.usageQuantity > includedFreeQuantity + ? includedFreeQuantity : workflowUsage.usageQuantity, - includedFreeQuantity: workflowUsage.includedFreeQuantity, + includedFreeQuantity, paidUsageQuantity: - workflowUsage.usageQuantity > workflowUsage.includedFreeQuantity - ? workflowUsage.usageQuantity - workflowUsage.includedFreeQuantity + workflowUsage.usageQuantity > includedFreeQuantity + ? workflowUsage.usageQuantity - includedFreeQuantity : 0, unitPriceCents: workflowUsage.unitPriceCents, totalCostCents: workflowUsage.totalCostCents, diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts index 1c4540fae..75430e1cd 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts @@ -14,6 +14,7 @@ export enum BillingExceptionCode { BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND', BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_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_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND', BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED', diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts index 64405120d..1ba4e2911 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts @@ -17,7 +17,10 @@ export class BillingMeteredProductUsageOutput { usageQuantity: number; @Field(() => Number) - includedFreeQuantity: number; + freeTierQuantity: number; + + @Field(() => Number) + freeTrialQuantity: number; @Field(() => Number) unitPriceCents: number; diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts index 96084e843..48e2c9512 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription-item.entity.ts @@ -15,6 +15,7 @@ import { 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 { BillingSubscriptionItemMetadata } from 'src/engine/core-modules/billing/types/billing-subscription-item-metadata.type'; @Entity({ name: 'billingSubscriptionItem', schema: 'core' }) @Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [ 'billingSubscriptionId', @@ -40,7 +41,7 @@ export class BillingSubscriptionItem { stripeSubscriptionId: string; @Column({ nullable: false, type: 'jsonb', default: {} }) - metadata: Stripe.Metadata; + metadata: BillingSubscriptionItemMetadata; @Column({ nullable: true, type: 'jsonb' }) billingThresholds: Stripe.SubscriptionItem.BillingThresholds; diff --git a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts index d146efada..34904f2d2 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts @@ -43,6 +43,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter { case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND: case BillingExceptionCode.BILLING_PLAN_NOT_FOUND: case BillingExceptionCode.BILLING_METER_NOT_FOUND: + case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND: return this.httpExceptionHandlerService.handleError( exception, response, diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts index 7bbe771b5..edc2cab89 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts @@ -45,7 +45,7 @@ export class UpdateSubscriptionQuantityJob { await this.stripeSubscriptionItemService.updateSubscriptionItem( billingBaseProductSubscriptionItem.stripeSubscriptionItemId, - workspaceMembersCount, + { quantity: workspaceMembersCount }, ); this.logger.log( diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-item.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-item.service.ts index f8b4a9d4a..23ed7981e 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-item.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-item.service.ts @@ -9,13 +9,16 @@ import { } from 'src/engine/core-modules/billing/billing.exception'; 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 { 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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @Injectable() export class BillingSubscriptionItemService { constructor( @InjectRepository(BillingSubscriptionItem, 'core') private readonly billingSubscriptionItemRepository: Repository, + private readonly twentyConfigService: TwentyConfigService, ) {} async getMeteredSubscriptionItemDetails(subscriptionId: string) { @@ -48,7 +51,8 @@ export class BillingSubscriptionItemService { stripeSubscriptionItemId: item.stripeSubscriptionItemId, productKey: item.billingProduct.metadata.productKey, stripeMeterId, - includedFreeQuantity: this.getIncludedFreeQuantity(price), + freeTierQuantity: this.getFreeTierQuantity(price), + freeTrialQuantity: this.getFreeTrialQuantity(item), unitPriceCents: this.getUnitPrice(price), }; }); @@ -69,10 +73,24 @@ export class BillingSubscriptionItemService { return matchingPrice; } - private getIncludedFreeQuantity(price: BillingPrice): number { + private getFreeTierQuantity(price: BillingPrice): number { 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 { return Number( price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal || 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 f9057e9db..5e320a269 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 @@ -5,7 +5,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import assert from 'assert'; +import { differenceInDays } from 'date-fns'; import Stripe from 'stripe'; +import { isDefined } from 'twenty-shared/utils'; import { Not, Repository } from 'typeorm'; 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 { 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 { 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 { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.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 { 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 { 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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class BillingSubscriptionService { @@ -38,6 +43,8 @@ export class BillingSubscriptionService { @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, private readonly stripeCustomerService: StripeCustomerService, + private readonly twentyConfigService: TwentyConfigService, + private readonly stripeSubscriptionItemService: StripeSubscriptionItemService, ) {} async getCurrentBillingSubscriptionOrThrow(criteria: { @@ -238,4 +245,78 @@ export class BillingSubscriptionService { 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', + ), + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts index ece0de3dc..b7f82417f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts @@ -125,8 +125,8 @@ export class BillingUsageService { ); const totalCostCents = - meterEventsSum - item.includedFreeQuantity > 0 - ? (meterEventsSum - item.includedFreeQuantity) * item.unitPriceCents + meterEventsSum - item.freeTierQuantity > 0 + ? (meterEventsSum - item.freeTierQuantity) * item.unitPriceCents : 0; return { @@ -134,7 +134,8 @@ export class BillingUsageService { periodStart, periodEnd, usageQuantity: meterEventsSum, - includedFreeQuantity: item.includedFreeQuantity, + freeTierQuantity: item.freeTierQuantity, + freeTrialQuantity: item.freeTrialQuantity, unitPriceCents: item.unitPriceCents, totalCostCents, }; diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts index c438a1470..9da7ec14b 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts @@ -24,8 +24,11 @@ export class StripeSubscriptionItemService { ); } - async updateSubscriptionItem(stripeItemId: string, quantity: number) { - await this.stripe.subscriptionItems.update(stripeItemId, { quantity }); + async updateSubscriptionItem( + stripeItemId: string, + updateData: Stripe.SubscriptionItemUpdateParams, + ) { + await this.stripe.subscriptionItems.update(stripeItemId, updateData); } async createSubscriptionItem( 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 2983f5ee9..19e8f039c 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 @@ -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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; -@ObjectType('BillingProductMetadata') +@ObjectType() export class BillingProductMetadata { @Field(() => BillingPlanKey) planKey: BillingPlanKey; diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-subscription-item-metadata.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-subscription-item-metadata.type.ts new file mode 100644 index 000000000..aa02863f9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-subscription-item-metadata.type.ts @@ -0,0 +1,7 @@ +/* @license Enterprise */ + +export type BillingSubscriptionItemMetadata = + | { + trialPeriodFreeWorkflowCredits: number; + } + | Record; diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service.ts index b6f2b9ce8..b7405d9bf 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-alert.service.ts @@ -38,7 +38,10 @@ export class BillingWebhookAlertService { if (alert.title === TRIAL_PERIOD_ALERT_TITLE && isDefined(stripeMeterId)) { const subscription = await this.billingSubscriptionRepository.findOne({ where: { stripeCustomerId, status: SubscriptionStatus.Trialing }, - relations: ['billingSubscriptionItems'], + relations: [ + 'billingSubscriptionItems', + 'billingSubscriptionItems.billingProduct', + ], }); 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( { billingSubscriptionId: subscription.id, diff --git a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts index e3ad473be..a86f8f91d 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/webhooks/services/billing-webhook-subscription.service.ts @@ -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 { 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 { 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 { 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 { 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 { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; @@ -44,6 +46,7 @@ export class BillingWebhookSubscriptionService { private readonly workspaceRepository: Repository, @InjectRepository(BillingCustomer, 'core') private readonly billingCustomerRepository: Repository, + private readonly billingSubscriptionService: BillingSubscriptionService, @InjectRepository(FeatureFlag, 'core') private readonly featureFlagRepository: Repository, ) {} @@ -137,6 +140,24 @@ export class BillingWebhookSubscriptionService { 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 { stripeSubscriptionId: data.object.id, stripeCustomerId: data.object.customer, diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index ad9fd467e..f566bd91e 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -509,6 +509,33 @@ export class ConfigVariables { @ValidateIf((env) => env.IS_BILLING_ENABLED === true) 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({ group: ConfigVariablesGroup.BillingConfig, isSensitive: true,