From 1d4fc5ff4a7715617688bd7c1950424f0d618cce Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Tue, 1 Apr 2025 15:57:01 +0200 Subject: [PATCH] update subscription with metered products at trial ending (#11319) Context - Subscription with metered prices can't be 'paused' at the end of trialing period - Currently, pausing subscription have been the process we choose at Twenty Two solutions : - [x] (The chosen one!) Adding metered products when the trial period is ended. - [ ] Switching from 'paused' to 'past_due' status at the end of trialing period. Tricky because we should handle different cases of 'past_due' subscription status, some causing workspace suspension and some other not. closes https://github.com/twentyhq/core-team-issues/issues/676 --- .../billing-portal.workspace-service.ts | 20 ++++--- .../services/billing-subscription.service.ts | 52 +++++++++++++++++++ .../stripe-subscription-item.service.ts | 12 +++++ .../services/stripe-subscription.service.ts | 7 +++ .../billing-webhook-subscription.service.ts | 24 +++++++++ .../enums/feature-flag-key.enum.ts | 1 + 6 files changed, 108 insertions(+), 8 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index 8667a08ef..97057c2bd 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -62,11 +62,11 @@ export class BillingPortalWorkspaceService { const stripeCustomerId = subscription?.stripeCustomerId; - const stripeSubscriptionLineItems = - await this.getStripeSubscriptionLineItems({ - quantity, - billingPricesPerPlan, - }); + const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({ + quantity, + billingPricesPerPlan, + forTrialSubscription: !isDefined(subscription), + }); const checkoutSession = await this.stripeCheckoutService.createCheckoutSession({ @@ -128,9 +128,11 @@ export class BillingPortalWorkspaceService { private getStripeSubscriptionLineItems({ quantity, billingPricesPerPlan, + forTrialSubscription, }: { quantity: number; billingPricesPerPlan?: BillingGetPricesPerPlanResult; + forTrialSubscription: boolean; }): Stripe.Checkout.SessionCreateParams.LineItem[] { if (billingPricesPerPlan) { return [ @@ -138,9 +140,11 @@ export class BillingPortalWorkspaceService { price: billingPricesPerPlan.baseProductPrice.stripePriceId, quantity, }, - ...billingPricesPerPlan.meteredProductsPrices.map((price) => ({ - price: price.stripePriceId, - })), + ...(forTrialSubscription + ? [] + : billingPricesPerPlan.meteredProductsPrices.map((price) => ({ + price: price.stripePriceId, + }))), ]; } 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 e1550c750..01bc4cbe1 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,13 +14,16 @@ 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'; 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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.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 { 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -28,6 +31,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; export class BillingSubscriptionService { protected readonly logger = new Logger(BillingSubscriptionService.name); constructor( + private readonly stripeSubscriptionItemService: StripeSubscriptionItemService, private readonly stripeSubscriptionService: StripeSubscriptionService, private readonly billingPlanService: BillingPlanService, private readonly billingProductService: BillingProductService, @@ -35,6 +39,8 @@ export class BillingSubscriptionService { private readonly billingEntitlementRepository: Repository, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, + @InjectRepository(BillingProduct, 'core') + private readonly billingProductRepository: Repository, ) {} async getCurrentBillingSubscriptionOrThrow(criteria: { @@ -193,4 +199,50 @@ export class BillingSubscriptionService { return subscriptionItemsToUpdate; } + + async convertTrialSubscriptionToSubscriptionWithMeteredProducts( + billingSubscription: BillingSubscription, + ) { + const meteredProducts = ( + await this.billingProductRepository.find({ + where: { + active: true, + }, + relations: ['billingPrices'], + }) + ).filter( + (product) => + product.metadata.priceUsageBased === BillingUsageType.METERED, + ); + + // subscription update to enable metered product billing + await this.stripeSubscriptionService.updateSubscription( + billingSubscription.stripeSubscriptionId, + { + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, + }, + ); + + for (const meteredProduct of meteredProducts) { + const meteredProductPrice = meteredProduct.billingPrices.find( + (price) => price.active, + ); + + if (!meteredProductPrice) { + throw new BillingException( + `Cannot find active price for product ${meteredProduct.id}`, + BillingExceptionCode.BILLING_PRICE_NOT_FOUND, + ); + } + + await this.stripeSubscriptionItemService.createSubscriptionItem( + billingSubscription.stripeSubscriptionId, + meteredProductPrice.stripePriceId, + ); + } + } } 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 1f7a40b40..ee730a97b 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 @@ -27,4 +27,16 @@ export class StripeSubscriptionItemService { async updateSubscriptionItem(stripeItemId: string, quantity: number) { await this.stripe.subscriptionItems.update(stripeItemId, { quantity }); } + + async createSubscriptionItem( + stripeSubscriptionId: string, + stripePriceId: string, + quantity?: number | undefined, + ) { + await this.stripe.subscriptionItems.create({ + subscription: stripeSubscriptionId, + price: stripePriceId, + quantity, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts index 0462a62e5..7b279f61a 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts @@ -76,4 +76,11 @@ export class StripeSubscriptionService { items: stripeSubscriptionItemsToUpdate, }); } + + async updateSubscription( + stripeSubscriptionId: string, + data: Stripe.SubscriptionUpdateParams, + ) { + await this.stripe.subscriptions.update(stripeSubscriptionId, data); + } } 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 c0b58ff48..acdfb63e6 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 @@ -11,10 +11,13 @@ import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billin 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 { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.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'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; @@ -54,6 +57,9 @@ export class BillingWebhookSubscriptionService { private readonly workspaceRepository: Repository, @InjectRepository(BillingCustomer, 'core') private readonly billingCustomerRepository: Repository, + @InjectRepository(FeatureFlag, 'core') + private readonly featureFlagRepository: Repository, + private readonly billingSubscriptionService: BillingSubscriptionService, ) {} async processStripeEvent( @@ -117,6 +123,24 @@ export class BillingWebhookSubscriptionService { }, ); + const wasTrialOrPausedSubscription = [ + SubscriptionStatus.Trialing, + SubscriptionStatus.Paused, + ].includes(data.previous_attributes?.status as SubscriptionStatus); + + const isMeteredProductBillingEnabled = + await this.featureFlagRepository.findOneBy({ + key: FeatureFlagKey.IsMeteredProductBillingEnabled, + workspaceId, + value: true, + }); + + if (wasTrialOrPausedSubscription && isMeteredProductBillingEnabled) { + await this.billingSubscriptionService.convertTrialSubscriptionToSubscriptionWithMeteredProducts( + updatedBillingSubscription, + ); + } + if ( BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[ WorkspaceActivationStatus.SUSPENDED diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index f1d26e099..de0eb266f 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -13,4 +13,5 @@ export enum FeatureFlagKey { IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED', IsWorkflowFormActionEnabled = 'IS_WORKFLOW_FORM_ACTION_ENABLED', IsPermissionsV2Enabled = 'IS_PERMISSIONS_V2_ENABLED', + IsMeteredProductBillingEnabled = 'IS_METERED_PRODUCT_BILLING_ENABLED', }