From cc53cb3b7b11c23dff0652b23a21795586944f8c Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre <61988046+anamarn@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:28:13 -0300 Subject: [PATCH] Add metered product to checkout session (#9788) Solves https://github.com/twentyhq/private-issues/issues/238 **TLDR:** Add metered product in the checkout session when purchasing a subscription **In order to test:** 1. Have the environment variable IS_BILLING_ENABLED set to true and add the other required environment variables for Billing to work 2. Do a database reset (to ensure that the new feature flag is properly added and that the billing tables are created) 3. Run the command: npx nx run twenty-server:command billing:sync-plans-data (if you don't do that the products and prices will not be present in the database) 4. Run the server , the frontend, the worker, and the stripe listen command (stripe listen --forward-to http://localhost:3000/billing/webhooks) 5. Buy a subscription for the Acme workspace , in the checkout session you should see that there is two products --- .../core-modules/billing/billing.exception.ts | 1 + .../core-modules/billing/billing.resolver.ts | 56 +++++---- .../billing/services/billing-plan.service.ts | 46 ++++++++ .../billing-portal.workspace-service.ts | 108 +++++++++++++----- .../services/stripe-checkout.service.ts | 21 ++-- ...billing-get-prices-per-plan-result.type.ts | 7 ++ ...portal-checkout-session-parameters.type.ts | 14 +++ 7 files changed, 192 insertions(+), 61 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type.ts 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 8e5ae8fc1..e5d623085 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 @@ -11,6 +11,7 @@ export class BillingException extends CustomException { export enum BillingExceptionCode { BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND', + BILLING_PLAN_NOT_FOUND = 'BILLING_PLAN_NOT_FOUND', BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND', BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND', BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND', diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 11df9347c..99e5540dc 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -16,6 +16,7 @@ import { BillingPlanService } from 'src/engine/core-modules/billing/services/bil import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; +import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type'; import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util'; 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'; @@ -84,42 +85,49 @@ export class BillingResolver { workspace.id, ); - let productPrice; + const checkoutSessionParams: BillingPortalCheckoutSessionParameters = { + user, + workspace, + successUrlPath, + plan: plan ?? BillingPlanKey.PRO, + requirePaymentMethod, + }; if (isBillingPlansEnabled) { - const baseProduct = await this.billingPlanService.getPlanBaseProduct( - plan ?? BillingPlanKey.PRO, - ); + const billingPricesPerPlan = + await this.billingPlanService.getPricesPerPlan({ + planKey: checkoutSessionParams.plan, + interval: recurringInterval, + }); + const checkoutSessionURL = + await this.billingPortalWorkspaceService.computeCheckoutSessionURL({ + ...checkoutSessionParams, + billingPricesPerPlan, + }); - if (!baseProduct) { - throw new GraphQLError('Base product not found'); - } - - productPrice = baseProduct.billingPrices.find( - (price) => price.interval === recurringInterval, - ); - } else { - productPrice = await this.stripePriceService.getStripePrice( - AvailableProduct.BasePlan, - recurringInterval, - ); + return { + url: checkoutSessionURL, + }; } + const productPrice = await this.stripePriceService.getStripePrice( + AvailableProduct.BasePlan, + recurringInterval, + ); + if (!productPrice) { throw new GraphQLError( 'Product price not found for the given recurring interval', ); } + const checkoutSessionURL = + await this.billingPortalWorkspaceService.computeCheckoutSessionURL({ + ...checkoutSessionParams, + priceId: productPrice.stripePriceId, + }); return { - url: await this.billingPortalWorkspaceService.computeCheckoutSessionURL( - user, - workspace, - productPrice.stripePriceId, - successUrlPath, - plan, - requirePaymentMethod, - ), + url: checkoutSessionURL, }; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts index ba3d35678..cecf60a80 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-plan.service.ts @@ -9,8 +9,10 @@ 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 { 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'; +import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type'; @Injectable() export class BillingPlanService { @@ -104,4 +106,48 @@ export class BillingPlanService { }; }); } + + async getPricesPerPlan({ + planKey, + interval, + }: { + planKey: BillingPlanKey; + interval: SubscriptionInterval; + }): Promise { + const plans = await this.getPlans(); + const plan = plans.find((plan) => plan.planKey === planKey); + + if (!plan) { + throw new BillingException( + 'Billing plan not found', + BillingExceptionCode.BILLING_PLAN_NOT_FOUND, + ); + } + const { baseProduct, meteredProducts, otherLicensedProducts } = plan; + const baseProductPrice = baseProduct.billingPrices.find( + (price) => price.interval === interval, + ); + + if (!baseProductPrice) { + throw new BillingException( + 'Base product price not found for given interval', + BillingExceptionCode.BILLING_PRICE_NOT_FOUND, + ); + } + const filterPricesByInterval = (product: BillingProduct) => + product.billingPrices.filter((price) => price.interval === interval); + + const meteredProductsPrices = meteredProducts.flatMap( + filterPricesByInterval, + ); + const otherLicensedProductsPrices = otherLicensedProducts.flatMap( + filterPricesByInterval, + ); + + return { + baseProductPrice, + meteredProductsPrices, + otherLicensedProductsPrices, + }; + } } 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 850953825..f95789a32 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 @@ -2,16 +2,22 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'class-validator'; +import Stripe from 'stripe'; import { Repository } from 'typeorm'; +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; -import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; -import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service'; import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service'; +import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type'; +import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.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'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { assert } from 'src/utils/assert'; @@ -22,21 +28,22 @@ export class BillingPortalWorkspaceService { private readonly stripeCheckoutService: StripeCheckoutService, private readonly stripeBillingPortalService: StripeBillingPortalService, private readonly domainManagerService: DomainManagerService, + private readonly featureFlagService: FeatureFlagService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, - private readonly billingSubscriptionService: BillingSubscriptionService, ) {} - async computeCheckoutSessionURL( - user: User, - workspace: Workspace, - priceId: string, - successUrlPath?: string, - plan?: BillingPlanKey, - requirePaymentMethod?: boolean, - ): Promise { + async computeCheckoutSessionURL({ + user, + workspace, + billingPricesPerPlan, + successUrlPath, + plan, + priceId, + requirePaymentMethod, + }: BillingPortalCheckoutSessionParameters): Promise { const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({ subdomain: workspace.subdomain, }); @@ -56,23 +63,37 @@ export class BillingPortalWorkspaceService { }); const stripeCustomerId = subscription?.stripeCustomerId; + const isBillingPlansEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsBillingPlansEnabled, + workspace.id, + ); - const session = await this.stripeCheckoutService.createCheckoutSession({ - user, - workspaceId: workspace.id, - priceId, - quantity, - successUrl, - cancelUrl, - stripeCustomerId, - plan, - requirePaymentMethod, - withTrialPeriod: !isDefined(subscription), - }); + const stripeSubscriptionLineItems = + await this.getStripeSubscriptionLineItems({ + quantity, + isBillingPlansEnabled, + billingPricesPerPlan, + priceId, + }); - assert(session.url, 'Error: missing checkout.session.url'); + const checkoutSession = + await this.stripeCheckoutService.createCheckoutSession({ + user, + workspaceId: workspace.id, + stripeSubscriptionLineItems, + successUrl, + cancelUrl, + stripeCustomerId, + plan, + requirePaymentMethod, + withTrialPeriod: !isDefined(subscription), + isBillingPlansEnabled, + }); - return session.url; + assert(checkoutSession.url, 'Error: missing checkout.session.url'); + + return checkoutSession.url; } async computeBillingPortalSessionURLOrThrow( @@ -113,4 +134,39 @@ export class BillingPortalWorkspaceService { return session.url; } + + private getStripeSubscriptionLineItems({ + quantity, + isBillingPlansEnabled, + billingPricesPerPlan, + priceId, + }: { + quantity: number; + isBillingPlansEnabled: boolean; + billingPricesPerPlan?: BillingGetPricesPerPlanResult; + priceId?: string; + }): Stripe.Checkout.SessionCreateParams.LineItem[] { + if (isBillingPlansEnabled && billingPricesPerPlan) { + return [ + { + price: billingPricesPerPlan.baseProductPrice.stripePriceId, + quantity, + }, + ...billingPricesPerPlan.meteredProductsPrices.map((price) => ({ + price: price.stripePriceId, + })), + ]; + } + + if (priceId && !isBillingPlansEnabled) { + return [{ price: priceId, quantity }]; + } + + throw new BillingException( + isBillingPlansEnabled + ? 'Missing Billing prices per plan' + : 'Missing price id', + BillingExceptionCode.BILLING_PRICE_NOT_FOUND, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts index bab412a8d..82e23fab5 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts @@ -27,33 +27,28 @@ export class StripeCheckoutService { async createCheckoutSession({ user, workspaceId, - priceId, - quantity, + stripeSubscriptionLineItems, successUrl, cancelUrl, stripeCustomerId, plan = BillingPlanKey.PRO, requirePaymentMethod = true, withTrialPeriod, + isBillingPlansEnabled = false, }: { user: User; workspaceId: string; - priceId: string; - quantity: number; + stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; successUrl?: string; cancelUrl?: string; stripeCustomerId?: string; plan?: BillingPlanKey; requirePaymentMethod?: boolean; withTrialPeriod: boolean; + isBillingPlansEnabled: boolean; }): Promise { return await this.stripe.checkout.sessions.create({ - line_items: [ - { - price: priceId, - quantity, - }, - ], + line_items: stripeSubscriptionLineItems, mode: 'subscription', subscription_data: { metadata: { @@ -68,7 +63,11 @@ export class StripeCheckoutService { : 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS', ), trial_settings: { - end_behavior: { missing_payment_method: 'pause' }, + end_behavior: { + missing_payment_method: isBillingPlansEnabled + ? 'create_invoice' + : 'pause', + }, }, } : {}), diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type.ts new file mode 100644 index 000000000..cf3b613a1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type.ts @@ -0,0 +1,7 @@ +import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; + +export type BillingGetPricesPerPlanResult = { + baseProductPrice: BillingPrice; + meteredProductsPrices: BillingPrice[]; + otherLicensedProductsPrices: BillingPrice[]; +}; diff --git a/packages/twenty-server/src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type.ts b/packages/twenty-server/src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type.ts new file mode 100644 index 000000000..cafcbc755 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type.ts @@ -0,0 +1,14 @@ +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +export type BillingPortalCheckoutSessionParameters = { + user: User; + workspace: Workspace; + billingPricesPerPlan?: BillingGetPricesPerPlanResult; + successUrlPath?: string; + plan: BillingPlanKey; + priceId?: string; + requirePaymentMethod?: boolean; +};